Merge pull request 'feat(storing payment method): adding payment method storing feature consistent with the updated payment logic in freeleaps backend' (#16) from haolou_local into dev

Reviewed-on: freeleaps/freeleaps-service-hub#16
Reviewed-by: jingyao1991 <jingyao1991@noreply.gitea.freeleaps.mathmast.com>
This commit is contained in:
jingyao1991 2025-07-22 06:32:36 +00:00
commit 223457162f
14 changed files with 303 additions and 71 deletions

View File

@ -1,3 +1,17 @@
## [1.2.1](https://gitea.freeleaps.mathmast.com/freeleaps/freeleaps-service-hub/compare/v1.2.0...v1.2.1) (2025-04-30)
### Bug Fixes
* **cleaner:** update document cleaner job ([dd96819](https://gitea.freeleaps.mathmast.com/freeleaps/freeleaps-service-hub/commit/dd96819709b4e0eef46eefbd5004ff25f5cdd8cd))
# [1.2.0](https://gitea.freeleaps.mathmast.com/freeleaps/freeleaps-service-hub/compare/v1.1.1...v1.2.0) (2025-04-25)
### Features
* **log:** ensure parent directory exists before opening log file ([bb90b26](https://gitea.freeleaps.mathmast.com/freeleaps/freeleaps-service-hub/commit/bb90b2688344d028fb6d10323e362946be53612e))
## [1.1.1](https://dev.azure.com/freeleaps/freeleaps-service-hub/_git/freeleaps-service-hub/compare/v1.1.0...v1.1.1) (2025-03-17) ## [1.1.1](https://dev.azure.com/freeleaps/freeleaps-service-hub/_git/freeleaps-service-hub/compare/v1.1.0...v1.1.1) (2025-03-17)

View File

@ -1 +1 @@
1.1.1 1.2.1

View File

@ -9,9 +9,6 @@ class PaymentHub:
self.stripe_manager = StripeManager() self.stripe_manager = StripeManager()
return return
async def fetch_wechat_qr_code(self, project_id: str) -> Optional[Dict[str, any]]:
return await self.payment_manager.fetch_wechat_qr_code(project_id)
async def fetch_stripe_account_id(self, user_id: str) -> Optional[str]: async def fetch_stripe_account_id(self, user_id: str) -> Optional[str]:
return await self.payment_manager.fetch_stripe_account_id(user_id) return await self.payment_manager.fetch_stripe_account_id(user_id)

View File

@ -9,18 +9,6 @@ class PaymentManager:
def __init__(self) -> None: def __init__(self) -> None:
self.module_logger = ModuleLogger(sender_id=PaymentManager) self.module_logger = ModuleLogger(sender_id=PaymentManager)
async def fetch_wechat_qr_code(self, project_id: str) -> Optional[Dict[str, any]]:
project = await ProjectDoc.get(project_id)
proposer = project.proposer_id
income_profile = await IncomeProfileDoc.find_one(
IncomeProfileDoc.user_id == proposer
)
if income_profile:
return income_profile.bank_account.money_collecting_methods[
0
].wechat_qr_code
return None
async def fetch_stripe_account_id(self, user_id: str) -> Optional[str]: async def fetch_stripe_account_id(self, user_id: str) -> Optional[str]:
income_profile = await IncomeProfileDoc.find_one(IncomeProfileDoc.user_id == user_id) income_profile = await IncomeProfileDoc.find_one(IncomeProfileDoc.user_id == user_id)
if income_profile: if income_profile:

View File

@ -8,6 +8,7 @@ from stripe.error import SignatureVerificationError
from common.log.module_logger import ModuleLogger from common.log.module_logger import ModuleLogger
from decimal import Decimal from decimal import Decimal
import json import json
import httpx
stripe.api_key = app_settings.STRIPE_API_KEY stripe.api_key = app_settings.STRIPE_API_KEY
@ -90,6 +91,10 @@ class StripeManager:
properties={"session_id": session_id}, properties={"session_id": session_id},
) )
elif len(transactions) == 0: elif len(transactions) == 0:
await self.module_logger.log_error(
error="No transaction found for session_id: {}".format(session_id),
properties={"session_id": session_id},
)
return None return None
return transactions[0] return transactions[0]
@ -203,19 +208,25 @@ class StripeManager:
transaction.stripe_price_id = price.id transaction.stripe_price_id = price.id
await transaction.save() await transaction.save()
payment_link = stripe.PaymentLink.create( # Prepare payment link parameters with conditional application_fee_amount
line_items=[ payment_link_params = {
"line_items": [
{ {
"price": transaction.stripe_price_id, "price": transaction.stripe_price_id,
"quantity": 1, "quantity": 1,
} }
], ],
application_fee_amount=transaction.application_fee_amount, "on_behalf_of": transaction.to_stripe_account_id,
on_behalf_of=transaction.to_stripe_account_id, "transfer_data": {
transfer_data={
"destination": transaction.to_stripe_account_id, "destination": transaction.to_stripe_account_id,
}, },
) }
# Only add application_fee_amount if it's greater than 0
if transaction.application_fee_amount and transaction.application_fee_amount > 0:
payment_link_params["application_fee_amount"] = transaction.application_fee_amount
payment_link = stripe.PaymentLink.create(**payment_link_params)
if payment_link: if payment_link:
transaction.stripe_payment_link = payment_link.url transaction.stripe_payment_link = payment_link.url
@ -276,27 +287,37 @@ class StripeManager:
transaction.stripe_price_id = price.id transaction.stripe_price_id = price.id
await transaction.save() await transaction.save()
session = stripe.checkout.Session.create( # Prepare payment_intent_data with conditional application_fee_amount
payment_method_types=["card"], payment_intent_data = {
line_items=[ "on_behalf_of": transaction.to_stripe_account_id,
"transfer_data": {
"destination": transaction.to_stripe_account_id,
},
}
# Only add application_fee_amount if it's greater than 0
if transaction.application_fee_amount and transaction.application_fee_amount > 0:
payment_intent_data["application_fee_amount"] = transaction.application_fee_amount
session_params = {
"payment_method_types": ["card"],
"line_items": [
{ {
"price": transaction.stripe_price_id, "price": transaction.stripe_price_id,
"quantity": 1, "quantity": 1,
} }
], ],
payment_intent_data={ "payment_intent_data": payment_intent_data,
"on_behalf_of": transaction.to_stripe_account_id, "mode": "payment",
"application_fee_amount": transaction.application_fee_amount, "success_url": "{}/projects".format(self.site_url_root),
"transfer_data": { "cancel_url": "{}/projects".format(self.site_url_root),
"destination": transaction.to_stripe_account_id, }
},
},
mode="payment",
success_url="{}/projects".format( session = stripe.checkout.Session.create(**session_params)
self.site_url_root
), # needs to be set, local: http://localhost/
cancel_url="{}/projects".format(self.site_url_root),
)
if session: if session:
transaction.stripe_checkout_session_id = session.id transaction.stripe_checkout_session_id = session.id
@ -332,21 +353,211 @@ class StripeManager:
async def invoke_checkout_session_webhook( async def invoke_checkout_session_webhook(
self, event: dict self, event: dict
) -> Tuple[bool, Optional[str], Optional[str]]: ) -> Tuple[bool, Optional[str], Optional[str]]:
# Handle the checkout.session.completed event """
Handle checkout.session.completed webhook events from Stripe.
Updates transaction status and saves payment method information for future use.
"""
if event["type"] == "checkout.session.completed": if event["type"] == "checkout.session.completed":
session = event["data"]["object"] session = event["data"]["object"]
# Find and validate the transaction
transaction = await self.__fetch_transaction_by_session_id(session["id"]) transaction = await self.__fetch_transaction_by_session_id(session["id"])
if not transaction: if not transaction:
await self.module_logger.log_error( await self.module_logger.log_error(
error="Transaction not found for session_id: {}".format(session["id"]), error="Transaction not found for session_id: {}".format(session["id"]),
properties={"session_id": session["id"]}, properties={"session_id": session["id"]},
) )
return False return False, None, None
transaction.status = TransactionStatus.COMPLETED # Update transaction status to completed
transaction.updated_time = datetime.now(timezone.utc) await self.__update_transaction_status(transaction)
await transaction.save()
# Process and save payment method information
await self.__process_payment_method(session, transaction)
return True, transaction.project_id, transaction.milestone_index return True, transaction.project_id, transaction.milestone_index
return False, None, None return False, None, None
async def __update_transaction_status(self, transaction: StripeTransactionDoc) -> None:
"""
Update transaction status to completed and save to database.
"""
transaction.status = TransactionStatus.COMPLETED
transaction.updated_time = datetime.now(timezone.utc)
await transaction.save()
async def __process_payment_method(self, session: dict, transaction: StripeTransactionDoc) -> None:
"""
Extract payment method details from Stripe session and save to database.
Creates or finds customer and attaches payment method for future use.
"""
try:
# Get payment method details from Stripe
payment_method_info = await self.__extract_payment_method_info(session)
if not payment_method_info:
return
payment_method_id, card_details = payment_method_info
# Get or create Stripe customer for the user
customer_id = await self.__get_or_create_customer(transaction.from_user)
if not customer_id:
return
# Attach payment method to customer and save to database
await self.__attach_and_save_payment_method(
payment_method_id, card_details, customer_id, transaction.from_user
)
except Exception as payment_method_error:
await self.module_logger.log_error(
error=f"Error processing payment method: {payment_method_error}",
properties={"session_id": session["id"], "user_id": transaction.from_user}
)
async def __extract_payment_method_info(self, session: dict) -> Optional[Tuple[str, dict]]:
"""
Extract payment method ID and card details from Stripe session.
Returns tuple of (payment_method_id, card_details) or None if not found.
"""
try:
# Get the Stripe session to extract payment method details
stripe_session = stripe.checkout.Session.retrieve(session["id"])
payment_intent_id = stripe_session.get('payment_intent')
if not payment_intent_id:
return None
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
payment_method_id = payment_intent.get('payment_method')
if not payment_method_id:
return None
payment_method = stripe.PaymentMethod.retrieve(payment_method_id)
card_details = payment_method.get('card', {})
return payment_method_id, card_details
except Exception as e:
await self.module_logger.log_error(
error=f"Error extracting payment method info: {e}",
properties={"session_id": session["id"]}
)
return None
async def __get_or_create_customer(self, user_id: str) -> Optional[str]:
"""
Find existing Stripe customer by email or create new one.
Returns customer ID or None if creation fails.
"""
try:
# Generate email for user (fallback since we don't have access to user profile)
user_email = f"user_{user_id}@freeleaps.com"
# Search for existing customers by email
customers = stripe.Customer.list(email=user_email, limit=1)
if customers.data:
return customers.data[0].id
# Create new customer if not found
customer = stripe.Customer.create(
email=user_email,
metadata={"user_id": user_id}
)
return customer.id
except Exception as customer_error:
await self.module_logger.log_error(
error=f"Error getting/creating customer: {customer_error}",
properties={"user_id": user_id}
)
return None
async def __attach_and_save_payment_method(
self, payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Attach payment method to Stripe customer and save details to database.
Handles various error scenarios gracefully.
"""
try:
# Check if payment method is already attached to a customer
payment_method_obj = stripe.PaymentMethod.retrieve(payment_method_id)
if payment_method_obj.customer:
# Use the existing customer ID
customer_id = payment_method_obj.customer
else:
# Try to attach payment method to customer in Stripe
stripe.PaymentMethod.attach(
payment_method_id,
customer=customer_id
)
# Save to database
await self.__save_payment_method_to_db(
payment_method_id, card_details, customer_id, user_id
)
except stripe.error.InvalidRequestError as attach_error:
# Handle specific Stripe attachment errors
await self.__handle_attachment_error(
attach_error, payment_method_id, card_details, customer_id, user_id
)
except Exception as save_error:
await self.module_logger.log_error(
error=f"Error attaching payment method: {save_error}",
properties={"payment_method_id": payment_method_id, "user_id": user_id}
)
async def __save_payment_method_to_db(
self, payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Save payment method details to database if it doesn't already exist.
"""
from backend.infra.payment.models import StripePaymentMethodDoc
# Check if payment method already exists in our database
existing_payment_method = await StripePaymentMethodDoc.find_one(
StripePaymentMethodDoc.stripe_payment_method_id == payment_method_id
)
if existing_payment_method:
return # Already saved
# Save to our database
payment_method_doc = StripePaymentMethodDoc(
user_id=user_id,
stripe_customer_id=customer_id,
stripe_payment_method_id=payment_method_id,
card_last4=card_details.get('last4'),
card_brand=card_details.get('brand'),
card_exp_month=card_details.get('exp_month'),
card_exp_year=card_details.get('exp_year'),
created_time=datetime.now(timezone.utc),
updated_time=datetime.now(timezone.utc),
)
await payment_method_doc.save()
async def __handle_attachment_error(
self, attach_error: stripe.error.InvalidRequestError,
payment_method_id: str, card_details: dict, customer_id: str, user_id: str
) -> None:
"""
Handle specific Stripe attachment errors and still save to database when possible.
"""
error_message = str(attach_error).lower()
if "already attached" in error_message or "may not be used again" in error_message:
# Payment method can't be attached but we can still save to database
await self.__save_payment_method_to_db(
payment_method_id, card_details, customer_id, user_id
)
else:
# Log other attachment errors
await self.module_logger.log_error(
error=f"Error attaching payment method: {attach_error}",
properties={"payment_method_id": payment_method_id, "user_id": user_id}
)

View File

@ -12,7 +12,6 @@ class MoneyCollectionType(IntEnum):
UNSPECIFIED = 0 UNSPECIFIED = 0
MARKED_AS_PAID = 1 MARKED_AS_PAID = 1
UPLOAD_PROOF = 2 UPLOAD_PROOF = 2
WECHAT_QR_CODE = 3
STRIPE_CHECKOUT = 4 STRIPE_CHECKOUT = 4

View File

@ -24,3 +24,18 @@ class StripeTransactionDoc(Document):
class Settings: class Settings:
name = "stripe_transaction" name = "stripe_transaction"
class StripePaymentMethodDoc(Document):
user_id: str
stripe_customer_id: str
stripe_payment_method_id: str
card_last4: Optional[str] = None
card_brand: Optional[str] = None
card_exp_month: Optional[int] = None
card_exp_year: Optional[int] = None
created_time: datetime
updated_time: datetime
class Settings:
name = "stripe_payment_method"

View File

@ -5,9 +5,9 @@
# TODO: Add all models to backend_models # TODO: Add all models to backend_models
from backend.services.payment.models import IncomeProfileDoc, PaymentProfileDoc from backend.services.payment.models import IncomeProfileDoc, PaymentProfileDoc
from backend.services.project.models import ProjectDoc from backend.services.project.models import ProjectDoc
from backend.infra.payment.models import StripeTransactionDoc from backend.infra.payment.models import StripeTransactionDoc, StripePaymentMethodDoc
backend_models = [IncomeProfileDoc, PaymentProfileDoc, ProjectDoc, StripeTransactionDoc] backend_models = [IncomeProfileDoc, PaymentProfileDoc, ProjectDoc, StripeTransactionDoc, StripePaymentMethodDoc]
# backend_models.extend(code_models) # backend_models.extend(code_models)
# backend_models.extend(user_models) # backend_models.extend(user_models)
# backend_models.extend(profile_models) # backend_models.extend(profile_models)

View File

@ -2,6 +2,7 @@ from typing import List, Dict, Optional
from decimal import Decimal from decimal import Decimal
from beanie import Document from beanie import Document
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime
from backend.services.payment.constants import PaymentGateway from backend.services.payment.constants import PaymentGateway
from backend.infra.payment.constants import MoneyCollectionType, PaymentLocation from backend.infra.payment.constants import MoneyCollectionType, PaymentLocation
@ -23,7 +24,6 @@ class MoneyCollectingMethod(BaseModel):
location: Optional[PaymentLocation] location: Optional[PaymentLocation]
priority: int = 0 # less number has high priority to be used. priority: int = 0 # less number has high priority to be used.
stripe_account_id: Optional[str] stripe_account_id: Optional[str]
wechat_qr_code: Optional[str]
last_update_time: Optional[int] = None last_update_time: Optional[int] = None
@ -61,3 +61,6 @@ class PaymentProfileDoc(Document):
class Settings: class Settings:
name = "payment_profile" name = "payment_profile"

View File

@ -3,4 +3,3 @@ from enum import IntEnum
class PaymentGateway(IntEnum): class PaymentGateway(IntEnum):
STRIP = 1 STRIP = 1
WECHAT = 2

View File

@ -27,7 +27,6 @@ class MoneyCollectingMethod(BaseModel):
location: Optional[PaymentLocation] location: Optional[PaymentLocation]
priority: int = 0 # less number has high priority to be used. priority: int = 0 # less number has high priority to be used.
stripe_account_id: Optional[str] stripe_account_id: Optional[str]
wechat_qr_code: Optional[str]
last_update_time: Optional[int] = None last_update_time: Optional[int] = None

View File

@ -6,19 +6,6 @@ from fastapi.encoders import jsonable_encoder
router = APIRouter() router = APIRouter()
payment_hub = PaymentHub() payment_hub = PaymentHub()
# Web API
# Fetch wechat qr code
@router.get(
"/fetch_wechat_qr_code/{project_id}",
operation_id="fetch_wechat_qr_code",
summary="Fetch wechat qr code",
description="Fetch wechat qr code",
)
async def fetch_wechat_qr_code(
project_id: str
):
return await payment_hub.fetch_wechat_qr_code(project_id)
# Web API # Web API
# Fetch stripe account id # Fetch stripe account id
@router.get( @router.get(

View File

@ -209,3 +209,23 @@ async def handle_account_webhook(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
return JSONResponse(content={"status": "success"}) return JSONResponse(content={"status": "success"})
# Web API
# Detach payment method
@router.delete(
"/detach_payment_method/{payment_method_id}",
operation_id="detach_payment_method",
summary="Detach payment method from customer",
description="Detach a payment method from a Stripe customer",
)
async def detach_payment_method(payment_method_id: str):
try:
# Detach the payment method from Stripe
stripe.PaymentMethod.detach(payment_method_id)
return JSONResponse(content={"success": True, "message": "Payment method detached successfully"})
except Exception as e:
return JSONResponse(
status_code=400,
content={"success": False, "message": f"Failed to detach payment method: {str(e)}"}
)