diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 3adc8fca..fa7db72d 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -806,6 +806,15 @@ paths: mandatory: true - name: BIRTH_DATE mandatory: true + - currencyCode: EUR + minAmount: 100 + maxAmount: 1000000 + enabledTransactionTypes: + - OUTGOING + - INCOMING + requiredCounterpartyFields: + - name: FULL_NAME + mandatory: true responses: '200': description: Configuration updated successfully @@ -3182,7 +3191,7 @@ components: | INVALID_PUBKEY_FORMAT | Counterparty Public key format is invalid | | MISSING_REQUIRED_UMA_PARAMETERS | Counterparty required UMA parameters are missing | | SENDER_NOT_ACCEPTED | Sender is not accepted | - | AMOUNT_OUT_OF_RANGE | Amount is out of range | + | AMOUNT_OUT_OF_RANGE | Amount is outside the allowed min/max range. Check the min and max values from the receiver lookup response. | | INVALID_CURRENCY | Currency is invalid | | INVALID_TIMESTAMP | Timestamp is invalid | | INVALID_NONCE | Nonce is invalid | @@ -3489,13 +3498,27 @@ components: minAmount: type: integer format: int64 - description: Minimum amount that can be sent in the smallest unit of this currency + description: Minimum amount that can be sent in the smallest unit of this currency. Note that the effective minimum for transactions may be higher than this value if the underlying UMA provider enforces a higher minimum (see umaProviderMinAmount). The effective minimum used in receiver lookups is max(minAmount, umaProviderMinAmount). minimum: 0 example: 100 maxAmount: type: integer format: int64 - description: Maximum amount that can be sent in the smallest unit of this currency + description: Maximum amount that can be sent in the smallest unit of this currency. Note that the effective maximum for transactions may be lower than this value if the underlying UMA provider enforces a lower maximum (see umaProviderMaxAmount). The effective maximum used in receiver lookups is min(maxAmount, umaProviderMaxAmount). + minimum: 0 + example: 1000000 + umaProviderMinAmount: + type: integer + format: int64 + description: The minimum transaction amount enforced by the underlying UMA provider for this currency, in the smallest unit of the currency. This is a read-only value that reflects provider-level constraints such as minimum trade values on the underlying exchange (which may be denominated in BTC and converted to this currency at current rates). The effective minimum for transactions is max(minAmount, umaProviderMinAmount). + readOnly: true + minimum: 0 + example: 500 + umaProviderMaxAmount: + type: integer + format: int64 + description: The maximum transaction amount enforced by the underlying UMA provider for this currency, in the smallest unit of the currency. This is a read-only value that reflects provider-level constraints. The effective maximum for transactions is min(maxAmount, umaProviderMaxAmount). + readOnly: true minimum: 0 example: 1000000 requiredCounterpartyFields: @@ -4014,13 +4037,13 @@ components: min: type: integer format: int64 - description: The minimum amount that can be received in this currency. + description: The minimum amount that can be received in this currency, in the smallest unit of the currency (e.g. cents). This is the effective minimum, accounting for both the platform-configured minimum and any provider-level constraints (such as minimum trade values on the underlying exchange). Sending amounts that would result in a receiving amount below this value will be rejected. exclusiveMinimum: 0 - example: 1 + example: 500 max: type: integer format: int64 - description: The maximum amount that can be received in this currency. + description: The maximum amount that can be received in this currency, in the smallest unit of the currency (e.g. cents). This is the effective maximum, accounting for both the platform-configured maximum and any provider-level constraints. exclusiveMinimum: 0 example: 1000000 Error412: diff --git a/mintlify/remittances/developer-guides/payment-error-handling.mdx b/mintlify/remittances/developer-guides/payment-error-handling.mdx index 78d907f5..23f895c5 100644 --- a/mintlify/remittances/developer-guides/payment-error-handling.mdx +++ b/mintlify/remittances/developer-guides/payment-error-handling.mdx @@ -7,4 +7,36 @@ This guide provides information about error handling in payments in UMA as a Ser ## Overview +When creating quotes or executing payments, you may encounter errors related to amount limits, currency mismatches, or provider-level constraints. Understanding these errors helps you build a robust payment experience. + +## Common Quote Errors + +### AMOUNT_OUT_OF_RANGE + +This error occurs when the transaction amount falls outside the allowed limits. The allowed range is determined by the `min` and `max` values returned in the receiver lookup response for the target currency. + + +The `min` value from the receiver lookup accounts for **both** the platform-configured minimum and the underlying provider's minimum trade value. The provider minimum may be denominated in BTC and converted to the receiving currency at current rates, so it can fluctuate with market conditions. + +For example, if the provider requires a minimum trade of 0.0001 BTC and the current BTC/EUR rate makes that approximately €7, the `min` for EUR will be at least 700 (in cents), even if the platform's configured minimum is 100 (€1.00). + + +To avoid this error: + +1. Always check the `min` and `max` values from the receiver lookup response before presenting amount options to your users +2. Validate amounts client-side before creating a quote +3. If locking the sending side, estimate the receiving amount using the `estimatedExchangeRate` and verify it falls within range + +```json +{ + "status": 400, + "code": "AMOUNT_OUT_OF_RANGE", + "message": "Amount is outside the allowed min/max range" +} +``` + +### QUOTE_REQUEST_FAILED + +This error indicates a retryable issue during the quote process. You can safely retry the request. + ## Error Handling \ No newline at end of file diff --git a/mintlify/remittances/developer-guides/platform-configuration.mdx b/mintlify/remittances/developer-guides/platform-configuration.mdx index ca3979ea..53743c20 100644 --- a/mintlify/remittances/developer-guides/platform-configuration.mdx +++ b/mintlify/remittances/developer-guides/platform-configuration.mdx @@ -160,9 +160,32 @@ The `supportedCurrencies` array allows you to define settings for each currency - `currencyCode`: (String, required) The ISO 4217 currency code (e.g., "USD"). - `minAmount`: (Integer, required) Minimum transaction amount in the smallest unit of the currency. - `maxAmount`: (Integer, required) Maximum transaction amount in the smallest unit of the currency. +- `umaProviderMinAmount`: (Integer, read-only) The minimum transaction amount enforced by the underlying UMA provider for this currency. See [Provider-Level Limits](#provider-level-limits) below. +- `umaProviderMaxAmount`: (Integer, read-only) The maximum transaction amount enforced by the underlying UMA provider for this currency. See [Provider-Level Limits](#provider-level-limits) below. - `requiredCounterpartyFields`: (Array, required) Defines PII your platform requires about *external counterparties* for transactions in this currency. - `umaProviderRequiredUserFields`: (Array, read-only) Lists user info field names (from `UserInfoFieldName`) that the UMA provider mandates for *your own users* to transact in this currency. This impacts user creation/updates. +### Provider-Level Limits + +The underlying UMA provider (e.g., the exchange used for currency conversion) may enforce its own minimum and maximum transaction amounts. These limits are exposed as read-only fields in the currency configuration: + +- `umaProviderMinAmount`: The provider's minimum, in the smallest unit of the currency. +- `umaProviderMaxAmount`: The provider's maximum, in the smallest unit of the currency. + + +Provider minimums may be higher than your platform's configured `minAmount`. This commonly occurs because the underlying exchange enforces a minimum trade value denominated in BTC, which is then converted to the target currency at current market rates. For example, a BTC minimum trade value of 0.0001 BTC might translate to approximately €7 at current prices, even if your platform's `minAmount` for EUR is set to €1.00. + +The **effective** minimum used in receiver lookups and quote validation is `max(minAmount, umaProviderMinAmount)`. Similarly, the effective maximum is `min(maxAmount, umaProviderMaxAmount)`. + + +You can check the current provider limits by retrieving your platform configuration: + +```http +GET /config +``` + +If you find that the provider's minimum is significantly higher than your configured `minAmount`, consider updating your `minAmount` to match or exceed `umaProviderMinAmount` to avoid confusing your users with limits that appear lower than what is actually accepted. + ### Required Counterparty Fields Within each currency defined in `supportedCurrencies`, the `requiredCounterpartyFields` parameter allows you to specify what information your platform needs to collect from *external counterparties* (senders or receivers) involved in transactions with your users for that specific currency. diff --git a/mintlify/remittances/developer-guides/sending-payments.mdx b/mintlify/remittances/developer-guides/sending-payments.mdx index 7ac36e44..746263f2 100644 --- a/mintlify/remittances/developer-guides/sending-payments.mdx +++ b/mintlify/remittances/developer-guides/sending-payments.mdx @@ -77,7 +77,7 @@ Response: "decimals": 2 }, "estimatedExchangeRate": 0.92, - "min": 100, + "min": 500, "max": 9000000 } ], @@ -96,6 +96,12 @@ Response: This response tells you the currencies the recipient can receive and, crucially, any information the recipient's VASP requires about *your user (the sender)* in the `requiredPayerDataFields` array before a payment can be processed. + +The `min` and `max` values in each supported currency represent the **effective** limits for receiving amounts, accounting for both platform-configured limits and provider-level constraints. Provider-level constraints may include minimum trade values on the underlying exchange, which can be denominated in BTC and converted to the receiving currency at current rates. + +Always validate that the receiving amount falls within the `min` and `max` range before creating a quote. Amounts outside this range will be rejected. + + ## Step 2: Create a payment quote Generate a quote for the payment with locked exchange rates and fees. diff --git a/openapi.yaml b/openapi.yaml index 3adc8fca..fa7db72d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -806,6 +806,15 @@ paths: mandatory: true - name: BIRTH_DATE mandatory: true + - currencyCode: EUR + minAmount: 100 + maxAmount: 1000000 + enabledTransactionTypes: + - OUTGOING + - INCOMING + requiredCounterpartyFields: + - name: FULL_NAME + mandatory: true responses: '200': description: Configuration updated successfully @@ -3182,7 +3191,7 @@ components: | INVALID_PUBKEY_FORMAT | Counterparty Public key format is invalid | | MISSING_REQUIRED_UMA_PARAMETERS | Counterparty required UMA parameters are missing | | SENDER_NOT_ACCEPTED | Sender is not accepted | - | AMOUNT_OUT_OF_RANGE | Amount is out of range | + | AMOUNT_OUT_OF_RANGE | Amount is outside the allowed min/max range. Check the min and max values from the receiver lookup response. | | INVALID_CURRENCY | Currency is invalid | | INVALID_TIMESTAMP | Timestamp is invalid | | INVALID_NONCE | Nonce is invalid | @@ -3489,13 +3498,27 @@ components: minAmount: type: integer format: int64 - description: Minimum amount that can be sent in the smallest unit of this currency + description: Minimum amount that can be sent in the smallest unit of this currency. Note that the effective minimum for transactions may be higher than this value if the underlying UMA provider enforces a higher minimum (see umaProviderMinAmount). The effective minimum used in receiver lookups is max(minAmount, umaProviderMinAmount). minimum: 0 example: 100 maxAmount: type: integer format: int64 - description: Maximum amount that can be sent in the smallest unit of this currency + description: Maximum amount that can be sent in the smallest unit of this currency. Note that the effective maximum for transactions may be lower than this value if the underlying UMA provider enforces a lower maximum (see umaProviderMaxAmount). The effective maximum used in receiver lookups is min(maxAmount, umaProviderMaxAmount). + minimum: 0 + example: 1000000 + umaProviderMinAmount: + type: integer + format: int64 + description: The minimum transaction amount enforced by the underlying UMA provider for this currency, in the smallest unit of the currency. This is a read-only value that reflects provider-level constraints such as minimum trade values on the underlying exchange (which may be denominated in BTC and converted to this currency at current rates). The effective minimum for transactions is max(minAmount, umaProviderMinAmount). + readOnly: true + minimum: 0 + example: 500 + umaProviderMaxAmount: + type: integer + format: int64 + description: The maximum transaction amount enforced by the underlying UMA provider for this currency, in the smallest unit of the currency. This is a read-only value that reflects provider-level constraints. The effective maximum for transactions is min(maxAmount, umaProviderMaxAmount). + readOnly: true minimum: 0 example: 1000000 requiredCounterpartyFields: @@ -4014,13 +4037,13 @@ components: min: type: integer format: int64 - description: The minimum amount that can be received in this currency. + description: The minimum amount that can be received in this currency, in the smallest unit of the currency (e.g. cents). This is the effective minimum, accounting for both the platform-configured minimum and any provider-level constraints (such as minimum trade values on the underlying exchange). Sending amounts that would result in a receiving amount below this value will be rejected. exclusiveMinimum: 0 - example: 1 + example: 500 max: type: integer format: int64 - description: The maximum amount that can be received in this currency. + description: The maximum amount that can be received in this currency, in the smallest unit of the currency (e.g. cents). This is the effective maximum, accounting for both the platform-configured maximum and any provider-level constraints. exclusiveMinimum: 0 example: 1000000 Error412: diff --git a/openapi/components/schemas/config/PlatformCurrencyConfig.yaml b/openapi/components/schemas/config/PlatformCurrencyConfig.yaml index 5af5f053..057e6841 100644 --- a/openapi/components/schemas/config/PlatformCurrencyConfig.yaml +++ b/openapi/components/schemas/config/PlatformCurrencyConfig.yaml @@ -7,13 +7,47 @@ properties: minAmount: type: integer format: int64 - description: Minimum amount that can be sent in the smallest unit of this currency + description: >- + Minimum amount that can be sent in the smallest unit of this currency. + Note that the effective minimum for transactions may be higher than this + value if the underlying UMA provider enforces a higher minimum (see + umaProviderMinAmount). The effective minimum used in receiver lookups is + max(minAmount, umaProviderMinAmount). minimum: 0 example: 100 maxAmount: type: integer format: int64 - description: Maximum amount that can be sent in the smallest unit of this currency + description: >- + Maximum amount that can be sent in the smallest unit of this currency. + Note that the effective maximum for transactions may be lower than this + value if the underlying UMA provider enforces a lower maximum (see + umaProviderMaxAmount). The effective maximum used in receiver lookups is + min(maxAmount, umaProviderMaxAmount). + minimum: 0 + example: 1000000 + umaProviderMinAmount: + type: integer + format: int64 + description: >- + The minimum transaction amount enforced by the underlying UMA provider + for this currency, in the smallest unit of the currency. This is a + read-only value that reflects provider-level constraints such as minimum + trade values on the underlying exchange (which may be denominated in BTC + and converted to this currency at current rates). The effective minimum + for transactions is max(minAmount, umaProviderMinAmount). + readOnly: true + minimum: 0 + example: 500 + umaProviderMaxAmount: + type: integer + format: int64 + description: >- + The maximum transaction amount enforced by the underlying UMA provider + for this currency, in the smallest unit of the currency. This is a + read-only value that reflects provider-level constraints. The effective + maximum for transactions is min(maxAmount, umaProviderMaxAmount). + readOnly: true minimum: 0 example: 1000000 requiredCounterpartyFields: diff --git a/openapi/components/schemas/errors/Error400.yaml b/openapi/components/schemas/errors/Error400.yaml index 5841a06a..0f790379 100644 --- a/openapi/components/schemas/errors/Error400.yaml +++ b/openapi/components/schemas/errors/Error400.yaml @@ -29,7 +29,7 @@ properties: | INVALID_PUBKEY_FORMAT | Counterparty Public key format is invalid | | MISSING_REQUIRED_UMA_PARAMETERS | Counterparty required UMA parameters are missing | | SENDER_NOT_ACCEPTED | Sender is not accepted | - | AMOUNT_OUT_OF_RANGE | Amount is out of range | + | AMOUNT_OUT_OF_RANGE | Amount is outside the allowed min/max range. Check the min and max values from the receiver lookup response. | | INVALID_CURRENCY | Currency is invalid | | INVALID_TIMESTAMP | Timestamp is invalid | | INVALID_NONCE | Nonce is invalid | diff --git a/openapi/components/schemas/receiver/CurrencyPreference.yaml b/openapi/components/schemas/receiver/CurrencyPreference.yaml index cd808bf2..f67f8c5f 100644 --- a/openapi/components/schemas/receiver/CurrencyPreference.yaml +++ b/openapi/components/schemas/receiver/CurrencyPreference.yaml @@ -18,12 +18,22 @@ properties: min: type: integer format: int64 - description: The minimum amount that can be received in this currency. + description: >- + The minimum amount that can be received in this currency, in the smallest + unit of the currency (e.g. cents). This is the effective minimum, + accounting for both the platform-configured minimum and any provider-level + constraints (such as minimum trade values on the underlying exchange). + Sending amounts that would result in a receiving amount below this value + will be rejected. exclusiveMinimum: 0 - example: 1 + example: 500 max: type: integer format: int64 - description: The maximum amount that can be received in this currency. + description: >- + The maximum amount that can be received in this currency, in the smallest + unit of the currency (e.g. cents). This is the effective maximum, + accounting for both the platform-configured maximum and any provider-level + constraints. exclusiveMinimum: 0 example: 1000000 diff --git a/openapi/paths/config/config.yaml b/openapi/paths/config/config.yaml index 5a2147eb..b346cfbe 100644 --- a/openapi/paths/config/config.yaml +++ b/openapi/paths/config/config.yaml @@ -67,6 +67,15 @@ patch: mandatory: true - name: BIRTH_DATE mandatory: true + - currencyCode: EUR + minAmount: 100 + maxAmount: 1000000 + enabledTransactionTypes: + - OUTGOING + - INCOMING + requiredCounterpartyFields: + - name: FULL_NAME + mandatory: true responses: '200': description: Configuration updated successfully diff --git a/samples/kotlin/umaaas-quickstart/frontend/src/components/forms/PaymentInitiation.tsx b/samples/kotlin/umaaas-quickstart/frontend/src/components/forms/PaymentInitiation.tsx index 8ad01d46..e8a09ef7 100644 --- a/samples/kotlin/umaaas-quickstart/frontend/src/components/forms/PaymentInitiation.tsx +++ b/samples/kotlin/umaaas-quickstart/frontend/src/components/forms/PaymentInitiation.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { User } from '../../types/user.types'; import { ApiResponse, LookupResponse } from '../../types/payment.types'; -import { convertToSmallestUnit } from '../../lib/currency-utils'; +import { convertToSmallestUnit, convertFromSmallestUnit } from '../../lib/currency-utils'; import ResponseDisplay from '../shared/ResponseDisplay'; import LoadingButton from '../shared/LoadingButton'; @@ -21,6 +21,7 @@ export default function PaymentInitiation({ currUser, lookupResponse, onQuoteSuc const [lastEditedField, setLastEditedField] = useState<'usd' | 'receiving'>('usd'); const [isCreatingQuote, setIsCreatingQuote] = useState(false); const [quoteResponse, setQuoteResponse] = useState(null); + const [amountError, setAmountError] = useState(null); // Set receiving currency when lookup succeeds useEffect(() => { @@ -32,10 +33,24 @@ export default function PaymentInitiation({ currUser, lookupResponse, onQuoteSuc } }, [lookupResponse]); + const validateReceivingAmount = (receivingAmountStr: string, currency: NonNullable[0]): string | null => { + const decimals = currency.currency.decimals; + const amountInMinorUnits = convertToSmallestUnit(receivingAmountStr, decimals); + const minDisplay = convertFromSmallestUnit(currency.min, decimals); + const maxDisplay = convertFromSmallestUnit(currency.max, decimals); + + if (amountInMinorUnits < currency.min) { + return `Receiving amount is below the minimum of ${minDisplay} ${currency.currency.code}`; + } + if (amountInMinorUnits > currency.max) { + return `Receiving amount exceeds the maximum of ${maxDisplay} ${currency.currency.code}`; + } + return null; + }; + const updateAmounts = (amount: string, field: 'usd' | 'receiving') => { if (!amount || !receivingCurrency || isUpdatingAmounts) return; - // Check if we have a lookup response with supported currencies const lookupData = lookupResponse?.data as LookupResponse; const supportedCurrency = lookupData?.supportedCurrencies?.[0]; @@ -45,14 +60,19 @@ export default function PaymentInitiation({ currUser, lookupResponse, onQuoteSuc setLastEditedField(field); try { + let newReceivingAmount: string; if (field === 'usd') { - // User updated sending amount: receiving = exchangeRate * sending const convertedAmount = (supportedCurrency.estimatedExchangeRate) * parseFloat(amount); - setReceivingAmount(convertedAmount.toFixed(6)); + newReceivingAmount = convertedAmount.toFixed(6); + setReceivingAmount(newReceivingAmount); } else { - // User updated receiving amount: sending = receiving / exchangeRate const convertedAmount = parseFloat(amount) / (supportedCurrency.estimatedExchangeRate); setUsdAmount(convertedAmount.toFixed(6)); + newReceivingAmount = amount; + } + + if (supportedCurrency.min && supportedCurrency.max) { + setAmountError(validateReceivingAmount(newReceivingAmount, supportedCurrency)); } } catch (error) { console.error('Error updating amounts:', error); @@ -181,10 +201,16 @@ export default function PaymentInitiation({ currUser, lookupResponse, onQuoteSuc + {amountError && ( +
+ {amountError} +
+ )} +
; requiredPayerDataFields?: Array<{ name: string; mandatory: boolean }>; }