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

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# 

12 

13 

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 

26 

27__all__ = ["AppStripeFxProxy", "FxEstimate"] 

28 

29 

30@dataclass(kw_only=True, frozen=True) 

31class FxEstimate: 

32 amount: int 

33 currency_code: str 

34 

35 

36class AppStripeFxProxy(ServiceProvider): 

37 

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: 

44 

45 import stripe 

46 

47 from flipdare.app_config import get_app_config 

48 

49 if secret_key is None: 

50 secret_key = get_app_config().stripe_secret_key 

51 stripe.api_key = secret_key 

52 

53 if stripe_client is None: 

54 stripe_client = stripe.StripeClient(stripe.api_key) 

55 

56 if conversion_account_id is None: 

57 conversion_account_id = get_app_config().currency_conversion_account_id 

58 

59 self._client = stripe_client 

60 self._conversion_account_id = conversion_account_id 

61 super().__init__() 

62 

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.. 

74 

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 

85 

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 ) 

93 

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 ) 

119 

120 # Convert minor units → major units 

121 major_amount = amount / from_currency.minor_unit_factor 

122 

123 # Apply FX 

124 converted_major = major_amount * exchange_rate 

125 

126 # Convert back to minor units (floor) 

127 converted_units = int(converted_major * to_currency.minor_unit_factor) 

128 

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 ) 

134 

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