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
« 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#
13from __future__ import annotations
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
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
51__all__ = ["PaymentWebhookHandler"]
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 )
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.
79 request = AppRequest.http(req, AppHttpRequestType.STRIPE_REFRESH)
80 endpoint = request.endpoint
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()
100 return self._create_webhook_link(request_adapter)
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
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()
127 uid = request_data.uid
128 stripe_account_id = request_data.stripe_account_id
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 )
137 # we refresh the account to determine the status, and return a result
138 # based on the status..
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)
148 try:
149 self.account_handler.refresh_account(endpoint=request_data.endpoint, user=user)
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)
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)
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
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 )
200 user: UserWrapper
201 stripe_settings: StripeSettingsType | None = None
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)
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)
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 )
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 # )
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)
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)
258 self.log_and_throw(endpoint=endpoint, message=msg, error_code=code)
260 return user