Skip to content

Conversation

@mainqueg
Copy link
Contributor

@mainqueg mainqueg commented Jan 23, 2026


Open with Devin

Summary by CodeRabbit

  • New Features

    • Added support for Manteca inquiry scope in KYC verification workflows
    • Integrated new Ramp payment provider for on-ramp transactions
    • Added webhook event handling for Manteca user status updates
  • Tests

    • Comprehensive test coverage for Manteca KYC and onboarding flows added

✏️ Tip: You can customize this high-level summary in your review settings.

@changeset-bot
Copy link

changeset-bot bot commented Jan 23, 2026

🦋 Changeset detected

Latest commit: 3f12e45

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Jan 23, 2026

Walkthrough

This PR introduces manteca inquiry support to the KYC and ramp integration system. It migrates the ramp route from "/onramp" to "/ramp", extends KYC scope validation to accept "manteca" alongside "basic", implements a full manteca onboarding flow with document and photo uploads, updates provider signatures to remove templateId parameters, and adds webhook handling for user status updates with deprecation response.

Changes

Cohort / File(s) Summary
Route and Integration Migration
server/api/index.ts
Replaced onramp route with ramp route integration.
KYC Scope Expansion
server/api/kyc.ts
Added manteca scope validation alongside basic, integrated error handling for getPendingInquiryTemplate with NOT_SUPPORTED status responses for unsupported countries.
Ramp Provider Implementation
server/api/ramp.ts
Refactored onboarding endpoint from POST "/onboarding" to POST "/", removed templateId validation, updated manteca and bridge provider calls with simplified error handling, restructured provider aggregation to directly reference provider responses.
Manteca Webhooks
server/hooks/manteca.ts
Added USER_STATUS_UPDATE webhook event with deprecation handling, standardized all response returns with explicit HTTP 200 status codes.
Persona Utilities
server/utils/persona.ts
Updated getPendingInquiryTemplate return type, added getDocumentForManteca function to select documents by id_class for given country, added NOT_SUPPORTED entry to scopeValidationErrors, reordered allowedMantecaCountries for Brazil.
Bridge Ramp Provider
server/utils/ramps/bridge.ts
Removed templateId from GetProvider and Onboarding types, restructured getProvider return shape to nest currencies and cryptoCurrencies under onramp object.
Manteca Ramp Provider
server/utils/ramps/manteca.ts
Implemented full manteca onboarding flow with chain validation, persona/account data fetching, identity document processing, photo uploads, and terms acceptance; simplified getProvider signature and return shape; removed templateId parameter and related schemas; added getExchange and getNationality helpers; pruned legacy error codes.
Shared Provider Types
server/utils/ramps/shared.ts
Removed MISSING_INFORMATION from ProviderStatus, added onramp object containing currencies and cryptoCurrencies to ProviderInfo, removed top-level currencies, cryptoCurrencies, and pendingTasks.
KYC Tests
server/test/api/kyc.test.ts
Added comprehensive manteca scope test suite covering GET/POST endpoints, inquiry creation, resumption, approval scenarios, and error handling.
Persona Utility Tests
server/test/utils/persona.test.ts
Added test suite for getDocumentForManteca function covering document selection, id_class validation, and priority handling.
Changeset
.changeset/wide-colts-wonder.md
Added patch version bump entry for @exactly/server package.

Sequence Diagrams

sequenceDiagram
    actor Client
    participant KYC as KYC Endpoint
    participant Persona as Persona Utilities
    participant Manteca as Manteca Provider
    participant MantecaAPI as Manteca API

    Client->>KYC: GET /kyc?scope=manteca
    KYC->>KYC: Validate scope (manteca allowed)
    KYC->>Persona: getPendingInquiryTemplate(account, manteca)
    Persona->>MantecaAPI: evaluateAccount(account)
    alt Account Supported
        MantecaAPI-->>Persona: template
        Persona-->>KYC: template/undefined
        alt Has Pending Inquiry
            KYC-->>Client: { ok, status, inquiry }
        else No Pending Inquiry
            KYC-->>Client: { ok, status: "not started" }
        end
    else Account Not Supported
        MantecaAPI-->>Persona: NOT_SUPPORTED error
        Persona-->>KYC: NOT_SUPPORTED error
        KYC-->>Client: 400 { code: "not supported" }
    end
Loading
sequenceDiagram
    actor Client
    participant Onboarding as Ramp Onboarding
    participant Bridge as Bridge Provider
    participant Manteca as Manteca Provider
    participant MantecaAPI as Manteca API
    participant PersonaAPI as Persona API

    Client->>Onboarding: POST /ramp onboarding request
    Onboarding->>Onboarding: Load credential
    
    alt Type: Bridge
        Onboarding->>Bridge: bridgeOnboarding(credentialId, customerId, acceptedTermsId)
        Bridge-->>Onboarding: result
    else Type: Manteca
        Onboarding->>MantecaAPI: Check chain/currency support
        MantecaAPI-->>Onboarding: validation result
        Onboarding->>PersonaAPI: Fetch account & documents
        PersonaAPI-->>Onboarding: account data
        Onboarding->>MantecaAPI: Upload identity document
        MantecaAPI-->>Onboarding: document processed
        Onboarding->>MantecaAPI: Upload front & back photos
        MantecaAPI-->>Onboarding: photos processed
        Onboarding->>Manteca: mantecaOnboarding(account, credentialId)
        Manteca->>MantecaAPI: Accept terms & create user
        MantecaAPI-->>Manteca: user created
        Manteca-->>Onboarding: result
    end
    
    Onboarding-->>Client: { code: "ok" }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • cruzdanilo
  • nfmelendez
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main objective of the changeset: adding support for manteca inquiry functionality across server-side API endpoints and utilities.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch manteca

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link

Summary of Changes

Hello @mainqueg, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the server's capability to handle Know Your Customer (KYC) and on-ramp processes by integrating Manteca. It involves a substantial refactoring of the on-ramp module, introducing new logic for Manteca-specific user onboarding, and updating API interactions and data structures to support this new provider. The changes aim to provide a more robust and flexible system for managing user verification and fund transfers.

Highlights

  • Manteca Inquiry Support: Introduced comprehensive support for Manteca inquiries within the server-side KYC and on-ramp processes, including new API endpoints, data validation, and error handling.
  • API Refactoring and Renaming: The onramp API route and its corresponding file server/api/onramp.ts have been renamed to ramp (server/api/ramp.ts) to streamline the on-ramp functionality.
  • KYC Scope Expansion: The /kyc API endpoints now support a 'manteca' scope, allowing for Manteca-specific KYC flows and handling NOT_SUPPORTED errors during inquiry template retrieval.
  • Manteca Onboarding Implementation: The mantecaOnboarding function has been fully implemented to handle the complete Manteca onboarding process, including initiating onboarding with personal data, uploading identity documents, and accepting terms and conditions.
  • Schema and Utility Updates: Modified various schemas (ProviderInfo, NewUserResponse) and utility functions (getPendingInquiryTemplate, getDocumentForManteca, getProvider in manteca.ts) to align with the new Manteca integration and simplified error codes.
  • Enhanced Test Coverage: Added extensive unit tests for the manteca scope in server/test/api/kyc.test.ts and for the new getDocumentForManteca utility in server/test/utils/persona.test.ts to ensure robust functionality.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View issue and 6 additional flags in Devin Review.

Open in Devin Review

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate call to getMantecaDepositDetails in quote endpoint

In the /quote endpoint's manteca case, getMantecaDepositDetails is called twice with the same arguments - once inside the try block (line 150) and once outside (line 158). The second call on line 158 is redundant and wasteful.

Click to expand

Code flow

Lines 149-159 in server/api/ramp.ts:

try {
  depositInfo = getMantecaDepositDetails(query.currency, mantecaUser.exchange);
} catch (error) {
  // error handling...
}
depositInfo = getMantecaDepositDetails(query.currency, mantecaUser.exchange);
return c.json({ quote: await getMantecaQuote(`USDC_${query.currency}`), depositInfo });

If the first call (line 150) succeeds, depositInfo is assigned. Then line 158 immediately overwrites it by calling the exact same function with the same arguments. This is unnecessary duplication.

Impact

This causes unnecessary computation and could potentially throw an error on the second call that wasn't caught by the try-catch block.

(Refers to line 158)

Recommendation: Remove the duplicate call on line 158. The depositInfo variable is already assigned in the try block on line 150.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Manteca inquiries, significantly refactoring the KYC and ramp provider integration. Key changes include renaming the 'onramp' module to 'ramp', extending the KYC scope to include 'manteca', and streamlining the logic for retrieving provider information and handling Manteca onboarding. The changes also include comprehensive new tests for the Manteca scope, ensuring the correctness of the new flows. The refactoring improves maintainability by centralizing Persona-related logic and simplifying provider interfaces.

const account = parse(Address, credential.account);
setUser({ id: account });
setContext("exa", { credential });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The removal of the eslint-disable-next-line comment is a good improvement. It indicates that the condition if (scope === "basic" && credential.pandaId) is now correctly recognized as potentially necessary, likely due to the introduction of the 'manteca' scope.

const allowedMantecaCountries = new Map<Country, Allowed>([
["AR", { allowedIds: ["id", "pp"] }],
["BR", { allowedIds: ["id", "dl", "pp"] }],
["BR", { allowedIds: ["id", "pp", "dl"] }],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The reordering of dl and pp in the allowedMantecaCountries map for BR seems intentional, likely to reflect a priority in document selection, which is now handled by the getDocumentForManteca function. This is a subtle but important change for correctness.

account: string,
credentialId: string,
templateId: string,
countryCode?: string,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getProvider function's signature has been simplified by removing credentialId, templateId, and redirectURL. This is a positive change, as it indicates a clearer separation of concerns, with Persona-specific inquiry management now handled elsewhere (likely in server/api/kyc.ts).

Comment on lines 230 to +231
if (!mantecaUser) {
const [inquiry, personaAccount] = await Promise.all([
getInquiry(credentialId, templateId),
getAccount(credentialId, "manteca"),
]);
if (!inquiry || !personaAccount) throw new Error(ErrorCodes.NO_KYC);
if (inquiry.attributes.status !== "approved" && inquiry.attributes.status !== "completed") {
throw new Error(ErrorCodes.KYC_NOT_APPROVED);
}

const country = personaAccount.attributes["country-code"];

try {
validateIdentification(inquiry);
} catch (error) {
if (error instanceof Error && Object.values(ErrorCodes).includes(error.message)) {
switch (error.message) {
case ErrorCodes.COUNTRY_NOT_ALLOWED:
case ErrorCodes.ID_NOT_ALLOWED:
return { status: "NOT_AVAILABLE", currencies: [], cryptoCurrencies: [], pendingTasks: [] };
case ErrorCodes.BAD_KYC_ADDITIONAL_DATA: {
let mantecaRedirectURL: undefined | URL = undefined;
if (redirectURL) {
mantecaRedirectURL = new URL(redirectURL);
mantecaRedirectURL.searchParams.set("provider", "manteca" satisfies (typeof shared.RampProvider)[number]);
}
const inquiryTask: InferOutput<typeof shared.PendingTask> = {
type: "INQUIRY",
link: await resumeOrCreateMantecaInquiryOTL(credentialId, mantecaRedirectURL?.toString()),
displayText: "We need more information to complete your KYC",
currencies: getSupportedByCountry(country),
cryptoCurrencies: [],
};
return { status: "MISSING_INFORMATION", currencies, cryptoCurrencies: [], pendingTasks: [inquiryTask] };
}
}
captureException(error, { contexts: { inquiry } });
}
throw error;
}
return { status: "NOT_STARTED", currencies, cryptoCurrencies: [], pendingTasks: [] };
return { onramp: { currencies, cryptoCurrencies: [] }, status: "NOT_STARTED" };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The simplified logic for handling !mantecaUser in getProvider is much cleaner. Instead of complex Persona inquiry checks, it now directly returns a NOT_STARTED status with available currencies, delegating the full inquiry flow to the kyc endpoint.

Comment on lines +250 to 321
export async function mantecaOnboarding(account: string, credentialId: string) {
const supportedChainId = SupportedOnRampChainId[chain.id as (typeof shared.SupportedChainId)[number]];
if (!supportedChainId) {
captureMessage("manteca_not_supported_chain_id", { contexts: { chain }, level: "error" });
throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID);
}

const mantecaUser = await getUser(account.replace("0x", ""));
if (mantecaUser?.status === "ACTIVE") return;
if (mantecaUser?.status === "INACTIVE") throw new Error(ErrorCodes.MANTECA_USER_INACTIVE);
const personaAccount = await getAccount(credentialId, "manteca");
if (!personaAccount) throw new Error(ErrorCodes.NO_PERSONA_ACCOUNT);
const countryCode = personaAccount.attributes["country-code"];

if (!mantecaUser) {
await initiateOnboarding({
email: personaAccount.attributes["email-address"],
legalId: personaAccount.attributes.fields.tin.value,
externalId: account.replace("0x", ""),
type: "INDIVIDUAL",
exchange: getExchange(countryCode),
personalData: {
birthDate: personaAccount.attributes.fields.birthdate.value,
nationality: getNationality(countryCode),
phoneNumber: personaAccount.attributes.fields.phone_number.value,
surname: personaAccount.attributes.fields.name.value.last.value,
name: personaAccount.attributes.fields.name.value.first.value,
maritalStatus: "Soltero", // cspell:ignore soltero
sex:
personaAccount.attributes.fields.sex_1.value === "Male"
? "M"
: personaAccount.attributes.fields.sex_1.value === "Female"
? "F"
: "X",
isFacta: !personaAccount.attributes.fields.isnotfacta.value, // cspell:ignore isnotfacta
isPep: false,
isFep: false,
work: personaAccount.attributes.fields.economic_activity.value,
},
});
}

const identityDocument = getDocumentForManteca(personaAccount.attributes.fields.documents.value, countryCode);
if (!identityDocument) {
captureException(new Error("no identity document"), { contexts: { personaAccount } });
throw new Error(ErrorCodes.NO_DOCUMENT);
}

const document = await getDocument(identityDocument.id_document_id.value);
const frontDocumentURL = document.attributes["front-photo"]?.url;
const backDocumentURL = document.attributes["back-photo"]?.url;

const results = await Promise.allSettled([
uploadIdentityFile(
account.replace("0x", ""),
"FRONT",
document.attributes["front-photo"]?.filename ?? "front-photo.jpg",
frontDocumentURL,
),
uploadIdentityFile(
account.replace("0x", ""),
"BACK",
document.attributes["back-photo"]?.filename ?? "back-photo.jpg",
backDocumentURL,
),
acceptTermsAndConditions(account.replace("0x", "")),
]);

for (const result of results) {
result.status === "rejected" && captureException(result.reason, { extra: { account } });
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of mantecaOnboarding is a significant addition, detailing the steps required to onboard a user with Manteca, including initiating onboarding, fetching identity documents from Persona, uploading files, and accepting terms. This centralizes the onboarding logic and improves clarity.

Comment on lines +703 to +707
const getExchange = (countryCode: string): (typeof Exchange)[number] => {
const exchange = ExchangeByCountry[countryCode as (typeof CountryCode)[number]];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!exchange) throw new Error(`Invalid country: ${countryCode}`);
return exchange;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getExchange helper function includes a check for !exchange and throws an error. This approach aligns with the rule to throw an error when an expected record is not found, indicating a system or data integrity issue (5xx-level error). While throwing an error is appropriate here, validating the countryCode earlier could prevent reaching this state, improving robustness.

References
  1. Throw an error when an expected database record (like a card) is not found. This indicates a system or data integrity issue, not a client-side error that can be fixed by the user, so a 5xx-level error is more appropriate than a 4xx.

Comment on lines +710 to +713
const getNationality = (countryCode: string): string => {
const nationality = Nationality[countryCode as (typeof CountryCode)[number]];
if (!nationality) throw new Error(`Invalid country: ${countryCode}`);
return nationality;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to getExchange, the getNationality helper function also throws an error for an invalid countryCode. This consistency in error handling is good and aligns with the rule for handling missing expected records as system/data integrity issues. Earlier validation of countryCode could further enhance robustness.

References
  1. Throw an error when an expected database record (like a card) is not found. This indicates a system or data integrity issue, not a client-side error that can be fixed by the user, so a 5xx-level error is more appropriate than a 4xx.

Comment on lines 763 to 770
export const ErrorCodes = {
MULTIPLE_IDENTIFICATION_NUMBERS: "multiple identification numbers",
NO_IDENTIFICATION_NUMBER: "no identification number",
NO_IDENTIFICATION_CLASS: "no identification class",
BAD_KYC_ADDITIONAL_DATA: "bad kyc additional data",
NOT_SUPPORTED_CURRENCY: "not supported currency",
NOT_SUPPORTED_CHAIN_ID: "not supported chain id",
NOT_SUPPORTED_CURRENCY: "not supported currency",
MANTECA_USER_INACTIVE: "manteca user inactive",
COUNTRY_NOT_ALLOWED: "country not allowed",
MULTIPLE_DOCUMENTS: "multiple documents",
NO_PERSONA_ACCOUNT: "no persona account",
INVALID_ORDER_SIZE: "invalid order size",
KYC_NOT_APPROVED: "kyc not approved",
BAD_MANTECA_KYC: "bad manteca kyc",
ID_NOT_ALLOWED: "id not allowed",
NO_NON_FACTA: "no non facta",
NO_PERSONA_ACCOUNT: "no persona account",
NO_DOCUMENT: "no document",
NO_GENDER: "no gender",
NO_KYC: "no kyc",
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ErrorCodes object has been significantly simplified, removing many Persona-related error codes. This aligns with the refactoring that moves Persona-specific logic out of this module, making manteca.ts more focused on Manteca API interactions.

export type OnRampNetworkType = (typeof CryptoNetwork)[number] | (typeof FiatNetwork)[number];

export const ProviderStatus = ["NOT_STARTED", "ACTIVE", "ONBOARDING", "NOT_AVAILABLE", "MISSING_INFORMATION"] as const;
export const ProviderStatus = ["NOT_STARTED", "ACTIVE", "ONBOARDING", "NOT_AVAILABLE"] as const;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The removal of MISSING_INFORMATION from ProviderStatus simplifies the possible states for a provider. This aligns with the refactoring in manteca.ts where the detailed handling of missing Persona information is now managed differently.

Comment on lines 132 to 138
export const ProviderInfo = object({
onramp: object({
currencies: array(string()),
cryptoCurrencies: array(object({ cryptoCurrency: picklist(Cryptocurrency), network: picklist(CryptoNetwork) })),
}),
status: picklist(ProviderStatus),
currencies: array(string()),
cryptoCurrencies: array(object({ cryptoCurrency: picklist(Cryptocurrency), network: picklist(CryptoNetwork) })),
pendingTasks: optional(array(PendingTask)),
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ProviderInfo schema has been refactored to include currencies and cryptoCurrencies within a nested onramp object, and pendingTasks has been removed. This new structure provides a clearer separation of on-ramp specific details and simplifies the overall provider information schema.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@server/api/ramp.ts`:
- Around line 196-229: The onboarding handlers only return 400 for one specific
code each; update the error handling in the manteca and bridge cases (around
mantecaOnboarding and bridgeOnboarding) so that any error whose message is a
member of MantecaErrorCodes or BridgeErrorCodes returns c.json({ code:
error.message }, 400). Keep the existing captureException(error, { contexts: {
credential } }) and the instanceof Error check, replace the single-case switch
with a general check using
Object.values(MantecaErrorCodes).includes(error.message) and
Object.values(BridgeErrorCodes).includes(error.message) and return 400 for any
match instead of re-throwing.

In `@server/utils/ramps/manteca.ts`:
- Around line 250-321: In mantecaOnboarding the expression account.replace("0x",
"") is repeated multiple times; introduce a const userExternalId =
account.replace("0x", "") near the top of the function and replace all uses of
account.replace("0x", "") (calls to getUser, the externalId property passed to
initiateOnboarding, the first argument to uploadIdentityFile for FRONT/BACK, and
the argument to acceptTermsAndConditions, as well as any captureException extra
data that references the stripped account) with userExternalId to avoid repeated
string ops and improve readability.
- Around line 302-320: The current use of Promise.allSettled with only logging
can let critical operations silently fail; update the block around
Promise.allSettled (which runs uploadIdentityFile and acceptTermsAndConditions)
to treat failures as fatal: after awaiting results, inspect the results array
for any entry with status === "rejected", call captureException(result.reason, {
extra: { account } }) for each, and then throw a new Error (including a brief
context like "Manteca onboarding failed for account") or switch to Promise.all
so the function rejects immediately when any of uploadIdentityFile or
acceptTermsAndConditions fails; ensure you reference the uploadIdentityFile and
acceptTermsAndConditions calls and the results variable when making this change.

Comment on lines +196 to +229
switch (onboarding.provider) {
case "manteca":
try {
await mantecaOnboarding(credential.account, credentialId);
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(MantecaErrorCodes).includes(error.message)) {
switch (error.message) {
case MantecaErrorCodes.NO_DOCUMENT:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
case "bridge":
try {
await bridgeOnboarding({
credentialId,
customerId: credential.bridgeId,
templateId,
acceptedTermsId: onboarding.acceptedTermsId,
});
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) {
switch (error.message) {
case BridgeErrorCodes.ALREADY_ONBOARDED:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
case "bridge":
try {
await bridgeOnboarding({
credentialId,
customerId: credential.bridgeId,
acceptedTermsId: onboarding.acceptedTermsId,
});
} catch (error) {
captureException(error, { contexts: { credential } });
if (error instanceof Error && Object.values(BridgeErrorCodes).includes(error.message)) {
switch (error.message) {
case BridgeErrorCodes.ALREADY_ONBOARDED:
return c.json({ code: error.message }, 400);
}
throw error;
}
break;
}
return c.json({ code: "ok" });
},
);
throw error;
}
break;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find where MantecaErrorCodes is defined
fd -t f "manteca" server/ | head -20
rg -l "MantecaErrorCodes" server/ --type ts

Repository: exactly/exa

Length of output: 127


🏁 Script executed:

#!/bin/bash
# Get MantecaErrorCodes definition
rg -A 20 "export.*MantecaErrorCodes" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Get BridgeErrorCodes definition
rg -A 20 "export.*BridgeErrorCodes" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Check mantecaOnboarding function to understand error throwing behavior
rg -B 5 -A 30 "function mantecaOnboarding|export.*mantecaOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 2884


🏁 Script executed:

#!/bin/bash
# Check bridgeOnboarding function to understand error throwing behavior
rg -B 5 -A 30 "function bridgeOnboarding|export.*bridgeOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Find ErrorCodes definition
rg -B 5 -A 30 "export.*ErrorCodes|const.*ErrorCodes" server/ -t ts | head -100

Repository: exactly/exa

Length of output: 5703


🏁 Script executed:

#!/bin/bash
# Search for NO_DOCUMENT error
rg "NO_DOCUMENT" server/ -t ts -B 2 -A 2

Repository: exactly/exa

Length of output: 1729


🏁 Script executed:

#!/bin/bash
# Search for bridgeOnboarding definition
rg -B 5 -A 30 "bridgeOnboarding" server/ -t ts

Repository: exactly/exa

Length of output: 3174


🏁 Script executed:

#!/bin/bash
# Check if MantecaErrorCodes is imported or defined in ramp.ts
rg "MantecaErrorCodes|BridgeErrorCodes" server/api/ramp.ts -B 5 -A 5

Repository: exactly/exa

Length of output: 3350


🏁 Script executed:

#!/bin/bash
# Check context around error handling in onboarding endpoint
cat -n server/api/ramp.ts | sed -n '185,235p'

Repository: exactly/exa

Length of output: 2116


🏁 Script executed:

#!/bin/bash
# Look for comments explaining error handling strategy
rg -B 10 "case \"manteca\":" server/api/ramp.ts | head -30

Repository: exactly/exa

Length of output: 959


🏁 Script executed:

#!/bin/bash
# Check if onboarding endpoint is called from webhooks or other places
rg "onboarding|POST /" server/hooks/ -t ts -B 2 -A 2

Repository: exactly/exa

Length of output: 37


Handle all error codes consistently in onboarding endpoint.

The manteca and bridge onboarding error handling only catches specific error codes (NO_DOCUMENT for manteca, ALREADY_ONBOARDED for bridge) and returns 400, while other expected error codes like MANTECA_USER_INACTIVE, NO_PERSONA_ACCOUNT, and NOT_SUPPORTED_CHAIN_ID are re-thrown as 500 errors. This is inconsistent with the GET /details endpoint, which returns 400 for any error in MantecaErrorCodes or BridgeErrorCodes. All errors thrown by mantecaOnboarding and bridgeOnboarding are user-facing validation failures and should return 400 status.

🤖 Prompt for AI Agents
In `@server/api/ramp.ts` around lines 196 - 229, The onboarding handlers only
return 400 for one specific code each; update the error handling in the manteca
and bridge cases (around mantecaOnboarding and bridgeOnboarding) so that any
error whose message is a member of MantecaErrorCodes or BridgeErrorCodes returns
c.json({ code: error.message }, 400). Keep the existing captureException(error,
{ contexts: { credential } }) and the instanceof Error check, replace the
single-case switch with a general check using
Object.values(MantecaErrorCodes).includes(error.message) and
Object.values(BridgeErrorCodes).includes(error.message) and return 400 for any
match instead of re-throwing.

Comment on lines +250 to 321
export async function mantecaOnboarding(account: string, credentialId: string) {
const supportedChainId = SupportedOnRampChainId[chain.id as (typeof shared.SupportedChainId)[number]];
if (!supportedChainId) {
captureMessage("manteca_not_supported_chain_id", { contexts: { chain }, level: "error" });
throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID);
}

const mantecaUser = await getUser(account.replace("0x", ""));
if (mantecaUser?.status === "ACTIVE") return;
if (mantecaUser?.status === "INACTIVE") throw new Error(ErrorCodes.MANTECA_USER_INACTIVE);
const personaAccount = await getAccount(credentialId, "manteca");
if (!personaAccount) throw new Error(ErrorCodes.NO_PERSONA_ACCOUNT);
const countryCode = personaAccount.attributes["country-code"];

if (!mantecaUser) {
await initiateOnboarding({
email: personaAccount.attributes["email-address"],
legalId: personaAccount.attributes.fields.tin.value,
externalId: account.replace("0x", ""),
type: "INDIVIDUAL",
exchange: getExchange(countryCode),
personalData: {
birthDate: personaAccount.attributes.fields.birthdate.value,
nationality: getNationality(countryCode),
phoneNumber: personaAccount.attributes.fields.phone_number.value,
surname: personaAccount.attributes.fields.name.value.last.value,
name: personaAccount.attributes.fields.name.value.first.value,
maritalStatus: "Soltero", // cspell:ignore soltero
sex:
personaAccount.attributes.fields.sex_1.value === "Male"
? "M"
: personaAccount.attributes.fields.sex_1.value === "Female"
? "F"
: "X",
isFacta: !personaAccount.attributes.fields.isnotfacta.value, // cspell:ignore isnotfacta
isPep: false,
isFep: false,
work: personaAccount.attributes.fields.economic_activity.value,
},
});
}

const identityDocument = getDocumentForManteca(personaAccount.attributes.fields.documents.value, countryCode);
if (!identityDocument) {
captureException(new Error("no identity document"), { contexts: { personaAccount } });
throw new Error(ErrorCodes.NO_DOCUMENT);
}

const document = await getDocument(identityDocument.id_document_id.value);
const frontDocumentURL = document.attributes["front-photo"]?.url;
const backDocumentURL = document.attributes["back-photo"]?.url;

const results = await Promise.allSettled([
uploadIdentityFile(
account.replace("0x", ""),
"FRONT",
document.attributes["front-photo"]?.filename ?? "front-photo.jpg",
frontDocumentURL,
),
uploadIdentityFile(
account.replace("0x", ""),
"BACK",
document.attributes["back-photo"]?.filename ?? "back-photo.jpg",
backDocumentURL,
),
acceptTermsAndConditions(account.replace("0x", "")),
]);

for (const result of results) {
result.status === "rejected" && captureException(result.reason, { extra: { account } });
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider extracting repeated account.replace("0x", "") to a variable.

The expression account.replace("0x", "") appears 6 times in mantecaOnboarding. Extracting it to a const userExternalId = account.replace("0x", "") at the start would improve readability and avoid repeated string operations.

Proposed refactor
 export async function mantecaOnboarding(account: string, credentialId: string) {
+  const userExternalId = account.replace("0x", "");
   const supportedChainId = SupportedOnRampChainId[chain.id as (typeof shared.SupportedChainId)[number]];
   if (!supportedChainId) {
     captureMessage("manteca_not_supported_chain_id", { contexts: { chain }, level: "error" });
     throw new Error(ErrorCodes.NOT_SUPPORTED_CHAIN_ID);
   }
 
-  const mantecaUser = await getUser(account.replace("0x", ""));
+  const mantecaUser = await getUser(userExternalId);
   if (mantecaUser?.status === "ACTIVE") return;
   if (mantecaUser?.status === "INACTIVE") throw new Error(ErrorCodes.MANTECA_USER_INACTIVE);
   // ... and so on for other occurrences
🤖 Prompt for AI Agents
In `@server/utils/ramps/manteca.ts` around lines 250 - 321, In mantecaOnboarding
the expression account.replace("0x", "") is repeated multiple times; introduce a
const userExternalId = account.replace("0x", "") near the top of the function
and replace all uses of account.replace("0x", "") (calls to getUser, the
externalId property passed to initiateOnboarding, the first argument to
uploadIdentityFile for FRONT/BACK, and the argument to acceptTermsAndConditions,
as well as any captureException extra data that references the stripped account)
with userExternalId to avoid repeated string ops and improve readability.

Comment on lines +302 to +320
const results = await Promise.allSettled([
uploadIdentityFile(
account.replace("0x", ""),
"FRONT",
document.attributes["front-photo"]?.filename ?? "front-photo.jpg",
frontDocumentURL,
),
uploadIdentityFile(
account.replace("0x", ""),
"BACK",
document.attributes["back-photo"]?.filename ?? "back-photo.jpg",
backDocumentURL,
),
acceptTermsAndConditions(account.replace("0x", "")),
]);

for (const result of results) {
result.status === "rejected" && captureException(result.reason, { extra: { account } });
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Promise.allSettled silently swallows critical failures.

Using Promise.allSettled with only logging on rejection means the function succeeds even if all operations (document uploads and T&C acceptance) fail. The user would appear to complete onboarding but manteca won't have the required documents.

Consider either:

  1. Checking if any critical operation failed and throwing
  2. Using Promise.all if all operations are required for successful onboarding
Proposed fix to fail on critical errors
   const results = await Promise.allSettled([
     uploadIdentityFile(
       account.replace("0x", ""),
       "FRONT",
       document.attributes["front-photo"]?.filename ?? "front-photo.jpg",
       frontDocumentURL,
     ),
     uploadIdentityFile(
       account.replace("0x", ""),
       "BACK",
       document.attributes["back-photo"]?.filename ?? "back-photo.jpg",
       backDocumentURL,
     ),
     acceptTermsAndConditions(account.replace("0x", "")),
   ]);
 
+  const failures = results.filter((result) => result.status === "rejected");
   for (const result of results) {
     result.status === "rejected" && captureException(result.reason, { extra: { account } });
   }
+  
+  // fail if T&C acceptance failed (critical)
+  if (results[2]?.status === "rejected") {
+    throw new Error(ErrorCodes.ONBOARDING_FAILED);
+  }
🤖 Prompt for AI Agents
In `@server/utils/ramps/manteca.ts` around lines 302 - 320, The current use of
Promise.allSettled with only logging can let critical operations silently fail;
update the block around Promise.allSettled (which runs uploadIdentityFile and
acceptTermsAndConditions) to treat failures as fatal: after awaiting results,
inspect the results array for any entry with status === "rejected", call
captureException(result.reason, { extra: { account } }) for each, and then throw
a new Error (including a brief context like "Manteca onboarding failed for
account") or switch to Promise.all so the function rejects immediately when any
of uploadIdentityFile or acceptTermsAndConditions fails; ensure you reference
the uploadIdentityFile and acceptTermsAndConditions calls and the results
variable when making this change.

@sentry
Copy link

sentry bot commented Jan 23, 2026

Codecov Report

❌ Patch coverage is 13.68421% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.80%. Comparing base (bc8ac76) to head (3f12e45).

Files with missing lines Patch % Lines
server/utils/ramps/manteca.ts 7.31% 38 Missing ⚠️
server/api/ramp.ts 3.70% 26 Missing ⚠️
server/hooks/manteca.ts 0.00% 14 Missing ⚠️
server/api/kyc.ts 72.72% 3 Missing ⚠️
server/utils/ramps/bridge.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #680      +/-   ##
==========================================
- Coverage   61.84%   61.80%   -0.05%     
==========================================
  Files         169      169              
  Lines        5289     5304      +15     
  Branches     1496     1502       +6     
==========================================
+ Hits         3271     3278       +7     
- Misses       1845     1857      +12     
+ Partials      173      169       -4     
Flag Coverage Δ
e2e 59.91% <9.47%> (-0.10%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants