/**
* Tesser x Banco Nexa: a walk through the whole USDC to GTQ flow.
*
* Phase 1 scope: Nexa always pays one fixed beneficiary, the payout partner
* that performs the last-mile payout to remittance receivers. Nexa is not
* involved in the last mile here. The GTQ payout is synchronous: step 6
* returns the outcome, and a webhook arrives only if it later fails.
*
* This is a guided tour, not a test suite. It calls every endpoint in the
* order the real flow uses them, prints what it sent and what came back, and
* explains each step as it goes. Read the output top to bottom and you have
* seen the entire integration.
*
* One file, no dependencies, no setup. Node 20+, Bun, or Deno.
*
* node nexa-e2e.mjs # against the Tesser stub host
* node nexa-e2e.mjs https://your-host # against your own implementation
*
* The endpoints it talks to are stubs: they always answer with the example
* from the API reference, whatever you send. That is enough to see the
* shapes, the field names and the order of play.
*/
const BASE = (process.argv[2] ?? "https://nexa-poc-main-c82e5ac.zuplo.app")
.replace(/\/+$/, "");
const NEXA = `${BASE}/nexa`; // Nexa hosts these, Tesser calls them
const TESSER = `${BASE}/tesser`; // Tesser hosts these, Nexa calls them
// Shared during onboarding.
const ORGANIZATION_ID = "org_nexa_001";
const ACCOUNT_ID = "8f3b1e7c-9a2d-4f58-b6c0-4e7d2a9f1b3c";
// Tesser mints this before the first call. It identifies the quote in every
// later call and every webhook.
const QUOTE_ID = "9b2f6c1e-0000-4000-8000-000000000001";
let problems = 0;
const indent = (s) =>
s
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
/** Call an endpoint, show the exchange, hand back the response body. */
async function call(title, explanation, { method = "GET", url, body }) {
console.log(`\n${"=".repeat(72)}\n${title}\n${"=".repeat(72)}`);
console.log(explanation.trim());
console.log(`\n ${method} ${url}`);
if (body !== undefined) {
console.log(`\n request:\n${indent(JSON.stringify(body, null, 2))}`);
}
const res = await fetch(url, {
method,
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
console.log(`\n ${res.status} ${res.statusText}`);
if (!res.ok) problems += 1;
if (!text) return undefined;
try {
const json = JSON.parse(text);
console.log(`\n response:\n${indent(JSON.stringify(json, null, 2))}`);
return json;
} catch {
console.log(`\n response (not JSON):\n${indent(text.slice(0, 400))}`);
return undefined;
}
}
/** An event Nexa posts to Tesser's receiver. */
const deliverEvent = (title, explanation, type, data) =>
call(title, explanation, {
method: "POST",
url: `${TESSER}/v1/webhooks/nexa/${ORGANIZATION_ID}`,
body: {
event_id: crypto.randomUUID(),
event_type: type,
occurred_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
data: { tesser_quote_id: QUOTE_ID, ...data },
},
});
console.log("Tesser x Banco Nexa: walking the USDC to GTQ flow");
console.log(`against ${BASE}`);
await call(
"Step 1 of 7 . Tesser asks Nexa for a quote",
`Felix wants GTQ 250,000. Tesser mints a UUID for the quote first and sends
it, so a retry can never create a second quote. Nexa answers with the price
and, importantly, with the exact USDC amount Felix has to deliver: Nexa is
the only side that does this arithmetic.`,
{
method: "POST",
url: `${NEXA}/v1/quotes`,
body: {
tesser_quote_id: QUOTE_ID,
customer: "FELIX",
desired_gtq: "250000.00",
},
},
);
await call(
"Step 2 of 7 . Tesser accepts the quote",
`Felix says yes, so Tesser locks the rate. The body is empty: the quote id
is in the path, and accepting means accepting the quoted amount. Nexa comes
back with a deposit deadline, which is how long it will hold the rate.`,
{ method: "POST", url: `${NEXA}/v1/quotes/${QUOTE_ID}/accept`, body: {} },
);
await call(
"Step 3 of 7 . Tesser reports the USDC arriving at custody",
`Felix sends USDC to Nexa's BitGo wallet. Tesser holds the keys, sees it
arrive, and reports it along with the risk screening result.
This is a report, not a request. A 2xx means "recorded" and nothing more.
Nexa has not agreed to credit anything yet.`,
{
method: "POST",
url: `${NEXA}/v1/quotes/${QUOTE_ID}/deposits`,
body: {
tesser_deposit_id: "4e1a77c0-0000-4000-8000-000000000001",
usdc_amount: "32278.480000",
risk_status: "automatically_approved",
risk_status_reasons: [],
transaction_hashes: [
"0x8c4a1f2e3d4c5b6a7980f1e2d3c4b5a69780f1e2d3c4b5a68c4a1f2e3d4c5b6a",
],
},
},
);
await deliverEvent(
"Step 4 of 7 . Nexa decides to credit",
`Nexa's decision arrives as a webhook, not as the response to step 3. Here
the script plays Nexa, posting to Tesser's receiver.
Nexa could instead send quote.additional_funds_requested or quote.rejected.
Those two branches are described on the Credit decision page.`,
"quote.credit.sent",
{ quote_status: "CREDIT_SENT", gtq_amount: "250000.00" },
);
await deliverEvent(
"Step 5 of 7 . Nexa confirms the GTQ is in Felix's account",
`The gtq_amount on this event is the authoritative one. Tesser has to send
this exact number back when it instructs the payout.`,
"quote.credit.confirmed",
{
quote_status: "CREDIT_CONFIRMED",
gtq_amount: "250000.00",
credited_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
},
);
const payment = await call(
"Step 6 of 7 . Tesser instructs the GTQ payout",
`The amount is deliberately repeated here. Nexa checks it against what it
credited, which catches a wrong quote id before any money moves.
The payout is synchronous: this response IS the outcome. There is no pending
state to poll and no success webhook. Nexa sends an event only if the payout
later fails during processing.`,
{
method: "POST",
url: `${NEXA}/v1/payments`,
body: { tesser_quote_id: QUOTE_ID, gtq_amount: "250000.00" },
},
);
const paymentId = payment?.nexa_payment_id ?? "NXP-2026-000112";
await call(
"Step 7 of 7 . Nexa checks its USDC balance",
`Separate from the quote flow: Nexa has accumulated USDC and uses Tesser's
existing core API to see it. Withdrawing it is POST /v1/treasury/withdrawals
on the same host.`,
{ method: "GET", url: `${TESSER}/v1/accounts/${ACCOUNT_ID}` },
);
console.log(`\n${"=".repeat(72)}`);
console.log(`That is the whole flow.
If you want to poke at it further, two read endpoints answer "what state do
you think this is in?":
GET ${NEXA}/v1/quotes/${QUOTE_ID}
GET ${NEXA}/v1/payments/${paymentId}
And an id ending in "dead" makes any stub return that operation's error
instead of the happy path, for example:
${QUOTE_ID.slice(0, -4)}dead`);
if (problems > 0) {
console.log(`\n${problems} call(s) did not return a success status.`);
process.exit(1);
}