# Lifecycle

One state machine per quote. Every API response and every webhook in either
direction carries `quote_status` from this enum, so Tesser's logs and Nexa's
logs line up on the same vocabulary when something needs investigating.

## Scope of the MVP

- USDC is the only crypto asset; GTQ the only fiat currency.
- One customer: `FELIX` (closed enum). Generalising to a `customer_id` is
  deferred.
- Each customer has exactly one wallet and one bank account at Nexa.
- **One fixed beneficiary.** Nexa always pays the same destination, agreed
  out-of-band and never present in an API payload. That beneficiary is the
  **payout partner**, which performs the last-mile payout to the individual
  remittance receivers.
- **Nexa is not involved in the last mile.** No per-recipient beneficiary
  details, no destination objects, no beneficiary compliance fields. Nexa
  taking on last-mile remittance payouts is **a later stage of the
  programme, out of scope here**.
- One payment per quote. Deposits are repeatable per quote (required by the
  additional-funds flow), but the normal case is one.
- No pagination, no list endpoints. One environment.

## Contract rules

Four rules decide most of this integration's edge cases.

1. **Money is always a decimal string.** GTQ 2dp, USDC 6dp, price 6dp.
   Never a JSON number.
2. **Nexa computes every derived amount.** Tesser echoes `usdc_amount` and
   the credited GTQ; it never divides locally.
3. **Reporting a deposit is not requesting anything.** A `2xx` means
   "recorded". Nexa's credit / more-funds / reject decision comes back by
   webhook.
4. **Every POST is idempotent on a caller-minted ID.** A retry returns the
   current object; a conflicting body returns `409`. Network retries can
   never false-fail.

## The whole flow in one diagram

The five phase pages that follow each show their own slice of this diagram.
<N /> = Nexa-hosted (Tesser calls it), <T /> = Tesser-hosted (Nexa calls it);
in the diagram the same labels appear as `[N]` and `[T]`.

<Mermaid
  chart={`sequenceDiagram
    autonumber
    participant T as Tesser (orchestrator)
    participant N as Banco Nexa

    Note over T,N: Phase 1 · Quote
    T->>N: POST /v1/quotes [N]
    N-->>T: 201 CREATED (price, quoted_gtq, usdc_amount, expires_at)
    T->>N: POST /v1/quotes/{tesser_quote_id}/accept [N]
    N-->>T: 200 ACCEPTED (deposit_deadline)

    Note over T,N: Phase 2 · USDC deposit (Felix → BitGo, observed by Tesser)
    T->>N: POST /v1/quotes/{tesser_quote_id}/deposits [N]<br/>(usdc_amount, risk screening, tx hashes)
    N-->>T: 200 DEPOSIT_REPORTED (acknowledgment only)

    Note over T,N: Phase 3 · Nexa's credit decision (asynchronous)
    alt Nexa credits
        N->>T: webhook quote.credit.sent [T]
        N->>T: webhook quote.credit.confirmed (authoritative gtq_amount) [T]
    else Nexa requests more funds
        N->>T: webhook quote.additional_funds_requested [T]
        T->>N: POST .../deposits (another report) [N]
    else Nexa rejects
        N->>T: webhook quote.rejected [T]
    end

    Note over T,N: Phase 4 · GTQ payout to the fixed beneficiary (synchronous)
    T->>N: POST /v1/payments (gtq_amount cross-check) [N]
    N-->>T: 201 with the outcome: payment COMPLETED, or FAILED on a synchronous decline
    opt Failure surfacing after a COMPLETED response
        N->>T: webhook payment.status.changed (FAILED / RETURNED) [T]
    end

    Note over T,N: Phase 5 · Nexa treasury (existing Tesser core API)
    N->>T: GET /v1/accounts/{id} [T]
    N->>T: POST /v1/treasury/withdrawals [T]
    T->>N: Tesser webhooks: withdrawal status → Nexa receiver [N]`}
/>

## Quote lifecycle (shared state vocabulary)

<Mermaid
  chart={`stateDiagram-v2
    direction LR
    [*] --> CREATED
    CREATED --> ACCEPTED: accept (op 2)
    CREATED --> EXPIRED: expires_at passes
    ACCEPTED --> DEPOSIT_REPORTED: deposit report (op 3)
    ACCEPTED --> EXPIRED: deposit_deadline passes, nothing reported
    DEPOSIT_REPORTED --> DEPOSIT_REPORTED: quote.additional_funds_requested (no state change)
    DEPOSIT_REPORTED --> CREDIT_SENT: quote.credit.sent
    DEPOSIT_REPORTED --> REJECTED: quote.rejected
    CREDIT_SENT --> CREDIT_CONFIRMED: quote.credit.confirmed
    CREDIT_CONFIRMED --> PAYMENT_COMPLETED: create payment (op 5), synchronous
    CREDIT_CONFIRMED --> PAYMENT_FAILED: create payment (op 5), synchronous decline
    PAYMENT_COMPLETED --> PAYMENT_FAILED: payment.status.changed FAILED
    PAYMENT_COMPLETED --> PAYMENT_RETURNED: payment.status.changed RETURNED
    EXPIRED --> [*]
    REJECTED --> [*]
    PAYMENT_COMPLETED --> [*]
    PAYMENT_FAILED --> [*]
    PAYMENT_RETURNED --> [*]`}
/>

## State table

| `quote_status` | Set by | Terminal? | Meaning |
| --- | --- | --- | --- |
| `CREATED` | [Create quote](/api-nexa/quotes#create-quote-op-1) <N /> | no | Quote exists with a price and an `expires_at`. Not yet locked. |
| `ACCEPTED` | [Accept quote](/api-nexa/quotes#accept-quote-op-2) <N /> | no | Rate locked. Nexa holds it until `deposit_deadline`. |
| `DEPOSIT_REPORTED` | [Report USDC deposit](/api-nexa/deposits#report-usdc-deposit-op-3) <N /> | no | Tesser has attested at least one USDC delivery. Nexa owes a decision. |
| `CREDIT_SENT` | `quote.credit.sent` webhook <T /> | no | Nexa decided to credit; instruction submitted to core banking. |
| `CREDIT_CONFIRMED` | `quote.credit.confirmed` webhook <T /> | no | GTQ available in Felix's Nexa account. `gtq_amount` here is authoritative. |
| `PAYMENT_COMPLETED` | [Create payment](/api-nexa/payments#create-payment-op-5) <N />, **in the response** | yes, unless a failure event follows | Payout made to the fixed beneficiary. |
| `PAYMENT_FAILED` | [Create payment](/api-nexa/payments#create-payment-op-5) <N /> **in the response** (synchronous decline), or the `payment.status.changed` webhook <T /> (failure after a `COMPLETED` response) | **yes** | Payout declined or failed. Recovery is manual in the POC. |
| `PAYMENT_RETURNED` | `payment.status.changed` webhook <T /> | **yes** | Payout returned by the rail. Recovery is manual in the POC. |
| `EXPIRED` | time | **yes** | Acceptance deadline or deposit deadline passed with nothing reported. |
| `REJECTED` | `quote.rejected` webhook <T /> | **yes** | Nexa declines to credit. The USDC has already moved; see below. |

## Rules the diagram encodes

- `EXPIRED` is reachable only from `CREATED` (acceptance deadline missed) and
  `ACCEPTED` (no deposit reported by `deposit_deadline`). **Once any deposit
  is reported in time, the quote never auto-expires**, and Nexa owes a
  decision.
- `REJECTED` is Nexa's decision after a deposit report (via the
  `quote.rejected` webhook), never a synchronous deposit-call error. After
  rejection, [`GET /v1/quotes/{tesser_quote_id}`](/api-nexa/quotes#get-quote-optional-op-8)
  returns `quote_status: "REJECTED"`.
- **An additional-funds request does not change state**: the quote stays
  `DEPOSIT_REPORTED` and accumulates deposit reports while Nexa's decision is
  pending. Late reports after a decision are also accepted.
- Terminal states are `EXPIRED`, `REJECTED`, `PAYMENT_COMPLETED`,
  `PAYMENT_FAILED` and `PAYMENT_RETURNED`. Recovery from
  `PAYMENT_FAILED`/`PAYMENT_RETURNED`, and disposition of USDC after
  `REJECTED`, are manual and out-of-band in the POC
  ([open questions 4 and 7](/open-questions)).
- **The payout is synchronous.** The create payment *response* carries the
  outcome: `PAYMENT_COMPLETED`, or `PAYMENT_FAILED` when Nexa declines or
  cannot execute the payout during the call. There is no success webhook, and
  the only payout event is a failure that surfaces on the rail *after* a
  `COMPLETED` response — which is why `PAYMENT_COMPLETED` is the one terminal
  state that can still be left, moving to `PAYMENT_FAILED` or
  `PAYMENT_RETURNED`. How long after a `COMPLETED` response such a failure can
  still surface is [open question 5](/open-questions).

## Rejection is not an undo

The USDC has already moved on-chain before the deposit endpoint is ever
called. A `quote.rejected` decision means "Nexa will not credit GTQ", not "the
deposit didn't happen". What happens to the received USDC is operational and
out-of-band for the POC. It is called out here because it is the edge most
likely to cause a cross-company incident.

## Glossary

| Term | Meaning |
| --- | --- |
| `tesser_quote_id` | UUIDv4 minted by Tesser *before* the create-quote call. Doubles as the idempotency key and is the cross-reference for every later call and webhook. |
| `nexa_quote_id` | Nexa's own reference for the quote, returned for support and reconciliation. |
| `tesser_deposit_id` | UUIDv4 minted by Tesser per deposit report; idempotency key for that report. |
| `nexa_payment_id` | Nexa's identifier for the GTQ payout. |
| `quote_status` | The shared state machine value carried on every response and webhook. |
| `payment_status` | The payout's outcome. `COMPLETED` or `FAILED` can both come back synchronously on create payment; `RETURNED`, and a `FAILED` that only surfaces after a `COMPLETED` response, arrive via the failure webhook. |
| `usdc_gtq_price` | GTQ per 1 USDC, string, max 6 decimal places. |
| `quoted_gtq` | The GTQ Nexa will actually provide. May be less than `desired_gtq` (a partial fill). |
| `usdc_amount` | USDC Felix must deliver: `quoted_gtq / usdc_gtq_price`, rounded **up** at 6dp. Computed by Nexa. |
| Custody | Nexa's BitGo wallet, whose keys Tesser holds. Felix's USDC lands here. |

**Worked example**, used in every example and every stub response in this
portal: Felix wants GTQ `250000.00`; Nexa quotes `7.745100` GTQ per USDC; so
`usdc_amount` is `32278.472841` (rounded up at 6dp). Checking the round-up:
`32278.472841 × 7.745100 = 250000.0000008…`, which covers `quoted_gtq`.
