Coverage for functions \ flipdare \ payments \ app_stripe_fx_proxy.py: 98%
58 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
14from attr import dataclass
15from stripe import StripeClient, StripeError
16from flipdare.app_log import LOG
17from flipdare.constants import (
18 DEF_CURRENCY_CODE,
19 IS_DEBUG,
20)
21from flipdare.error import StripeErrorContext
22from flipdare.error.app_error import AppError
23from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode
24from flipdare.payments.data.fee_calculator import FeeCalculator
25from flipdare.service._service_provider import ServiceProvider
27__all__ = ["AppStripeFxProxy", "FxEstimate"]
30@dataclass(kw_only=True, frozen=True)
31class FxEstimate:
32 amount: int
33 currency_code: str
36class AppStripeFxProxy(ServiceProvider):
38 def __init__(
39 self,
40 secret_key: str | None = None,
41 stripe_client: StripeClient | None = None,
42 conversion_account_id: str | None = None,
43 ) -> None:
45 import stripe
47 from flipdare.app_config import get_app_config
49 if secret_key is None:
50 secret_key = get_app_config().stripe_secret_key
51 stripe.api_key = secret_key
53 if stripe_client is None:
54 stripe_client = stripe.StripeClient(stripe.api_key)
56 if conversion_account_id is None:
57 conversion_account_id = get_app_config().currency_conversion_account_id
59 self._client = stripe_client
60 self._conversion_account_id = conversion_account_id
61 super().__init__()
63 def estimate_for_currency(
64 self,
65 endpoint: str,
66 calculator: FeeCalculator,
67 ) -> FxEstimate:
68 """
69 NOTE: Stripe currently only supports conversion for accounts that are in USD.
70 NOTE: so we have to use a separate Stripe (flipdare admin account)
71 NOTE: account that has access to the FX Quotes API
72 NOTE: to get estimates for non-USD currencies.
73 NOTE 2: Stripe-Fx is currently early access and does not support conversion to all currencies..
75 this provides an estimate of the charge for a user currency.
76 If this fails, you will just have to charge in the required currency,
77 and the credit card provider/stripe will handle the FX conversion,
78 which may be less ideal for the user but is more likely to
79 succeed since it doesn't rely on your Stripe account having access to the FX Quotes API.
80 """
81 from_currency = calculator.from_currency
82 to_currency = calculator.to_currency
83 account_convert_id = self._conversion_account_id
84 amount = calculator.amount
86 # If transaction is in your base currency, use standard fee
87 if from_currency == to_currency:
88 LOG().debug(f"estimate for {amount} [{from_currency} to {to_currency}]")
89 return FxEstimate(
90 amount=calculator.amount,
91 currency_code=from_currency,
92 )
94 try:
95 # Create quote from transaction currency to base currency
96 LOG().debug(
97 f"Estimate charge of {amount} ({from_currency}) for "
98 f"conversion to {to_currency} using account {account_convert_id} ",
99 )
100 # if not supported will raise an exception..
101 fx_quote = self._client.v1.fx_quotes.create(
102 params={
103 "to_currency": to_currency.code,
104 "from_currencies": [from_currency.code],
105 "lock_duration": "hour",
106 "usage": {"type": "payment"},
107 },
108 options={
109 "stripe_version": "2026-01-28.preview",
110 "stripe_account": account_convert_id,
111 },
112 )
113 exchange_rate = fx_quote["rates"][from_currency]["exchange_rate"]
114 if IS_DEBUG:
115 LOG().debug(
116 f"FX Quote: exchange_rate={exchange_rate} for converting "
117 f"{from_currency} to {to_currency} for amount {amount}",
118 )
120 # Convert minor units → major units
121 major_amount = amount / from_currency.minor_unit_factor
123 # Apply FX
124 converted_major = major_amount * exchange_rate
126 # Convert back to minor units (floor)
127 converted_units = int(converted_major * to_currency.minor_unit_factor)
129 if IS_DEBUG:
130 LOG().debug(
131 f"Estimated amount in {DEF_CURRENCY_CODE}: {converted_units} for "
132 f"{amount} {from_currency} with exchange rate {exchange_rate}",
133 )
135 return FxEstimate(
136 amount=converted_units,
137 currency_code=DEF_CURRENCY_CODE,
138 )
139 except StripeError as err:
140 # Print the specific error message from Stripe's servers
141 # !! IMPORTANT !!
142 # !! IMPORTANT : this is a critical error as it could be an issue the account
143 # !! IMPORTANT : that is used for currency conversion.
144 msg = (
145 f"Failed to get FX quote from Stripe for converting "
146 f"{from_currency} to {to_currency} for amount {amount}"
147 )
148 LOG().error(msg)
149 ctx = StripeErrorContext.from_code(
150 endpoint=endpoint,
151 error_code=AppPaymentErrorCode.FX_ESTIMATE_ERROR,
152 cause=msg,
153 error=err,
154 )
155 self.app_logger.from_context("admin", ctx)
156 LOG().error(msg)
157 raise AppError.from_context(ctx) from err
158 except Exception as e:
159 LOG().error(f"Error calculating fee with currency conversion: {e!s}. ")
160 raise AppError(
161 source="AppCharge.estimate_for_currency",
162 message="An error occurred while calculating the application fee.",
163 error_code=AppPaymentErrorCode.FX_ESTIMATE_ERROR,
164 cause=str(e),
165 error=e,
166 ) from e