Coverage for functions \ flipdare \ service \ payments \ _payment_webhook_handler.py: 72%

115 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 

13from __future__ import annotations 

14 

15import flask 

16from typing import TYPE_CHECKING 

17from flipdare.app_log import LOG 

18from flipdare.constants import IS_DEBUG 

19from flipdare.generated.model.user_model import StripeSettingsType 

20from flipdare.generated.shared.stripe.stripe_onboard_code import StripeOnboardCode 

21from flipdare.generated.shared.stripe.stripe_onboard_state import StripeOnboardState 

22from flipdare.payments.stripe_webhook_response import StripeWebhookResponse 

23from flipdare.error import ( 

24 AppError, 

25 StripeErrorContext, 

26) 

27from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode 

28from flipdare.service._error_mixin import ErrorMixin 

29from flipdare.payments.app_stripe_proxy import AppStripeProxy 

30from flipdare.request import ( 

31 AppHttpRequestType, 

32 AppRequest, 

33 StripeWebhookRequestAdapter, 

34) 

35from flipdare.core.app_response import AppErrorResponse 

36from flipdare.service._service_provider import ServiceProvider 

37from flipdare.service._user_mixin import UserMixin 

38from flipdare.service.payments._base_payment_handler import BasePaymentHandler 

39from flipdare.payments.core.stripe_guard import StripeGuard 

40from flipdare.util.debug_util import stringify_debug 

41from flipdare.util.error_util import ErrorUtil 

42from flipdare.wrapper.user_wrapper import UserWrapper 

43 

44if TYPE_CHECKING: 

45 from flipdare.manager.service_manager import ServiceManager 

46 from flipdare.service.payments._payment_link_handler import PaymentLinkHandler 

47 from flipdare.service.payments._payment_account_handler import PaymentAccountHandler 

48 from flipdare.manager.backend_manager import BackendManager 

49 from flipdare.manager.db_manager import DbManager 

50 

51__all__ = ["PaymentWebhookHandler"] 

52 

53 

54class PaymentWebhookHandler(BasePaymentHandler, ErrorMixin, UserMixin, ServiceProvider): 

55 def __init__( 

56 self, 

57 proxy: AppStripeProxy, 

58 link_handler: PaymentLinkHandler, 

59 account_handler: PaymentAccountHandler, 

60 db_manager: DbManager | None = None, 

61 backend_manager: BackendManager | None = None, 

62 service_manager: ServiceManager | None = None, 

63 ) -> None: 

64 self.proxy = proxy 

65 self.link_handler = link_handler 

66 self.account_handler = account_handler 

67 super().__init__( 

68 db_manager=db_manager, 

69 backend_manager=backend_manager, 

70 service_manager=service_manager, 

71 ) 

72 

73 def handle_refresh(self, req: flask.Request) -> flask.Response: 

74 # NOTE: Stripe requires that the refresh URL returns a 200 status code 

75 # NOTE: with a body containing the URL to redirect to. 

76 # This is a bit weird, but we can just return a simple HTML page 

77 # with a meta refresh tag that redirects to the base return URL. 

78 

79 request = AppRequest.http(req, AppHttpRequestType.STRIPE_REFRESH) 

80 endpoint = request.endpoint 

81 

82 request_adapter: StripeWebhookRequestAdapter | None = None 

83 try: 

84 request_adapter = StripeWebhookRequestAdapter.from_http( 

85 req, 

86 req_type=AppHttpRequestType.STRIPE_REFRESH, 

87 ) 

88 request_adapter.validate() 

89 except AppError as e: 

90 msg = f"Webhook refresh validation error: {e}\n\tRequest: {request!s}" 

91 LOG().error(msg) 

92 error = StripeErrorContext.from_code( 

93 endpoint=endpoint, 

94 error_code=AppPaymentErrorCode.INVALID_REQUEST, 

95 cause=msg, 

96 error=e, 

97 ) 

98 return AppErrorResponse.from_context(error).raw_response() 

99 

100 return self._create_webhook_link(request_adapter) 

101 

102 def handle_return(self, req: flask.Request) -> flask.Response: 

103 """ 

104 User has completed the onboarding flow. 

105 """ 

106 request = AppRequest.http(req, AppHttpRequestType.STRIPE_RETURN) 

107 endpoint = request.endpoint 

108 

109 request_data: StripeWebhookRequestAdapter | None = None 

110 try: 

111 request_data = StripeWebhookRequestAdapter.from_http( 

112 req, 

113 req_type=AppHttpRequestType.STRIPE_RETURN, 

114 ) 

115 request_data.validate() 

116 except AppError as e: 

117 msg = f"Webhook return validation error: {e}\n\tRequest: {request!s}" 

118 LOG().error(msg) 

119 error = StripeErrorContext.from_code( 

120 endpoint=endpoint, 

121 error_code=AppPaymentErrorCode.INVALID_REQUEST, 

122 cause=msg, 

123 error=e, 

124 ) 

125 return AppErrorResponse.from_context(error).raw_response() 

126 

127 uid = request_data.uid 

128 stripe_account_id = request_data.stripe_account_id 

129 

130 if IS_DEBUG: 

131 LOG().debug( 

132 f"Stripe Request for {uid}/{stripe_account_id}:\n" 

133 f"\t{request_data.endpoint}\n" 

134 f"\tDATA\n{stringify_debug(request_data.params)}\n", 

135 ) 

136 

137 # we refresh the account to determine the status, and return a result 

138 # based on the status.. 

139 

140 user: UserWrapper | None = None 

141 try: 

142 user = self._get_user(endpoint=request_data.endpoint, uid=uid) 

143 except Exception as err: 

144 msg = f"Error getting user for webhook return: {err!s}" 

145 LOG().error(msg) 

146 return StripeWebhookResponse.return_hook(StripeOnboardCode.USER_MISSING) 

147 

148 try: 

149 self.account_handler.refresh_account(endpoint=request_data.endpoint, user=user) 

150 

151 settings = user.stripe_settings 

152 if settings is None or not StripeGuard.is_account(settings): 

153 msg = f"Invalid stripe settings for user {user.doc_id} after refresh" 

154 LOG().error(msg) 

155 return StripeWebhookResponse.return_hook(StripeOnboardCode.INVALID_SETTINGS) 

156 

157 onboard_state = settings.onboard_state 

158 match onboard_state: 

159 case StripeOnboardState.COMPLETED | StripeOnboardState.SUBMITTED: 

160 return StripeWebhookResponse.return_hook() 

161 case StripeOnboardState.DISABLED | StripeOnboardState.CLOSED: 

162 return StripeWebhookResponse.return_hook(StripeOnboardCode.STRIPE_CLOSED) 

163 case ( 

164 StripeOnboardState.UNKNOWN 

165 | StripeOnboardState.IN_PROGRESS 

166 | StripeOnboardState.NOT_STARTED 

167 ): 

168 return StripeWebhookResponse.return_hook(StripeOnboardCode.STRIPE_ERROR) 

169 case ( 

170 StripeOnboardState.DETAILS_NOT_SUBMITTED 

171 | StripeOnboardState.CHARGES_NOT_ENABLED 

172 | StripeOnboardState.PAYOUTS_NOT_ENABLED 

173 | StripeOnboardState.TRANSFERS_NOT_ENABLED 

174 | StripeOnboardState.CARD_PAYMENTS_NOT_ACTIVE 

175 | StripeOnboardState.PENDING_REQUIREMENTS 

176 | StripeOnboardState.NO_CAPABILITIES 

177 | StripeOnboardState.PAST_DUE_REQUIREMENTS 

178 | StripeOnboardState.NO_REQUIREMENTS 

179 ): 

180 return StripeWebhookResponse.return_hook(StripeOnboardCode.STRIPE_MORE_INFO) 

181 except Exception as error: 

182 # trigger close in the flutter, app, but report an error so the flutter app 

183 # can handle it.. 

184 LOG().error(f"Error processing redirect callback: {error!s}") 

185 return StripeWebhookResponse.return_hook(StripeOnboardCode.RETURN_ERROR) 

186 

187 def _create_webhook_link( 

188 self, 

189 request_adapter: StripeWebhookRequestAdapter, 

190 ) -> flask.Response: 

191 stripe_account_id = request_adapter.stripe_account_id 

192 uid = request_adapter.uid 

193 

194 LOG().info( 

195 f"Stripe Request: (uid={uid} stripe_account_id={stripe_account_id})\n" 

196 f"\t{request_adapter.endpoint}\n" 

197 f"\tDATA\n{stringify_debug(request_adapter.params) if request_adapter.params else 'None'}\n", 

198 ) 

199 

200 user: UserWrapper 

201 stripe_settings: StripeSettingsType | None = None 

202 

203 try: 

204 user = self.get_user_by_id(endpoint=request_adapter.endpoint, uid=uid) 

205 except Exception: 

206 # trigger close in the flutter, app, but report an error so the flutter app 

207 # can handle it.. 

208 return StripeWebhookResponse.return_hook(StripeOnboardCode.REFRESH_ERROR) 

209 

210 stripe_settings = user.stripe_settings 

211 if stripe_settings is None: 

212 msg = f"Unable to refresh URL for user {user.doc_id}: No stripe settings.." 

213 raise ValueError(msg) 

214 if not StripeGuard.is_account(stripe_settings): 

215 msg = f"Unable to refresh URL for user {user.doc_id}: Invalid stripe settings.." 

216 raise ValueError(msg) 

217 

218 try: 

219 # we want to force a new link to be created when refreshing, 

220 # since the old one is no longer valid 

221 # NOTE: this will automatically promote, if a customer 

222 user.update_stripe_account( 

223 account_id=request_adapter.stripe_account_id, 

224 account_type=request_adapter.stripe_account_type, 

225 country_code=stripe_settings.country_code, 

226 currency_code=stripe_settings.currency_code, 

227 ) 

228 # dont need to update the db as create_onboard_link 

229 # updates the database .. 

230 link = self.link_handler.create_onboard_link( 

231 endpoint=request_adapter.endpoint, 

232 user=user, 

233 overwrite=True, 

234 ) 

235 

236 # USE THIS FOR EMBEDDED LINKS ETC, BUT STRIPE REQUIRES 

237 # A 200 RESPONSE WITH THE URL IN THE BODY, 

238 # SO WE RETURN A SIMPLE HTML PAGE WITH A META REFRESH TAG INSTEAD 

239 # return flask.Response( 

240 # status=302, # 302 = Found / Temporary Redirect 

241 # headers={"Location": refresh_url} 

242 # ) 

243 

244 refresh_url = link["url"] 

245 return StripeWebhookResponse.refresh_hook(refresh_url) 

246 except Exception: 

247 # trigger close in the flutter, app, but report an error so the flutter app 

248 # can handle it.. 

249 return StripeWebhookResponse.return_hook(StripeOnboardCode.REFRESH_ERROR) 

250 

251 def _get_user(self, endpoint: str, uid: str) -> UserWrapper: 

252 # dont use user is not None, lint checkers dont recognize throw_ wont return 

253 user = self.user_db.get(uid) 

254 if user is None: 

255 LOG().error(f"No user found for id {uid}") 

256 code, msg = ErrorUtil.missing_uid_msg(uid) 

257 

258 self.log_and_throw(endpoint=endpoint, message=msg, error_code=code) 

259 

260 return user