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:
commit
223457162f
14
CHANGELOG.md
14
CHANGELOG.md
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -9,9 +9,6 @@ class PaymentHub:
|
||||
self.stripe_manager = StripeManager()
|
||||
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]:
|
||||
return await self.payment_manager.fetch_stripe_account_id(user_id)
|
||||
|
||||
|
||||
@ -9,18 +9,6 @@ class PaymentManager:
|
||||
def __init__(self) -> None:
|
||||
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]:
|
||||
income_profile = await IncomeProfileDoc.find_one(IncomeProfileDoc.user_id == user_id)
|
||||
if income_profile:
|
||||
|
||||
@ -8,6 +8,7 @@ from stripe.error import SignatureVerificationError
|
||||
from common.log.module_logger import ModuleLogger
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import httpx
|
||||
|
||||
|
||||
stripe.api_key = app_settings.STRIPE_API_KEY
|
||||
@ -90,6 +91,10 @@ class StripeManager:
|
||||
properties={"session_id": session_id},
|
||||
)
|
||||
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 transactions[0]
|
||||
@ -203,19 +208,25 @@ class StripeManager:
|
||||
transaction.stripe_price_id = price.id
|
||||
await transaction.save()
|
||||
|
||||
payment_link = stripe.PaymentLink.create(
|
||||
line_items=[
|
||||
# Prepare payment link parameters with conditional application_fee_amount
|
||||
payment_link_params = {
|
||||
"line_items": [
|
||||
{
|
||||
"price": transaction.stripe_price_id,
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
application_fee_amount=transaction.application_fee_amount,
|
||||
on_behalf_of=transaction.to_stripe_account_id,
|
||||
transfer_data={
|
||||
"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_link_params["application_fee_amount"] = transaction.application_fee_amount
|
||||
|
||||
payment_link = stripe.PaymentLink.create(**payment_link_params)
|
||||
|
||||
if payment_link:
|
||||
transaction.stripe_payment_link = payment_link.url
|
||||
@ -276,27 +287,37 @@ class StripeManager:
|
||||
transaction.stripe_price_id = price.id
|
||||
await transaction.save()
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[
|
||||
# Prepare payment_intent_data with conditional application_fee_amount
|
||||
payment_intent_data = {
|
||||
"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,
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
payment_intent_data={
|
||||
"on_behalf_of": transaction.to_stripe_account_id,
|
||||
"application_fee_amount": transaction.application_fee_amount,
|
||||
"transfer_data": {
|
||||
"destination": transaction.to_stripe_account_id,
|
||||
},
|
||||
},
|
||||
mode="payment",
|
||||
success_url="{}/projects".format(
|
||||
self.site_url_root
|
||||
), # needs to be set, local: http://localhost/
|
||||
cancel_url="{}/projects".format(self.site_url_root),
|
||||
)
|
||||
"payment_intent_data": payment_intent_data,
|
||||
"mode": "payment",
|
||||
"success_url": "{}/projects".format(self.site_url_root),
|
||||
"cancel_url": "{}/projects".format(self.site_url_root),
|
||||
}
|
||||
|
||||
|
||||
|
||||
session = stripe.checkout.Session.create(**session_params)
|
||||
|
||||
if session:
|
||||
transaction.stripe_checkout_session_id = session.id
|
||||
@ -332,21 +353,211 @@ class StripeManager:
|
||||
async def invoke_checkout_session_webhook(
|
||||
self, event: dict
|
||||
) -> 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":
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Find and validate the transaction
|
||||
transaction = await self.__fetch_transaction_by_session_id(session["id"])
|
||||
if not transaction:
|
||||
await self.module_logger.log_error(
|
||||
error="Transaction not found for session_id: {}".format(session["id"]),
|
||||
properties={"session_id": session["id"]},
|
||||
)
|
||||
return False
|
||||
return False, None, None
|
||||
|
||||
transaction.status = TransactionStatus.COMPLETED
|
||||
transaction.updated_time = datetime.now(timezone.utc)
|
||||
await transaction.save()
|
||||
# Update transaction status to completed
|
||||
await self.__update_transaction_status(transaction)
|
||||
|
||||
# Process and save payment method information
|
||||
await self.__process_payment_method(session, transaction)
|
||||
|
||||
return True, transaction.project_id, transaction.milestone_index
|
||||
|
||||
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}
|
||||
)
|
||||
|
||||
@ -12,7 +12,6 @@ class MoneyCollectionType(IntEnum):
|
||||
UNSPECIFIED = 0
|
||||
MARKED_AS_PAID = 1
|
||||
UPLOAD_PROOF = 2
|
||||
WECHAT_QR_CODE = 3
|
||||
STRIPE_CHECKOUT = 4
|
||||
|
||||
|
||||
|
||||
@ -24,3 +24,18 @@ class StripeTransactionDoc(Document):
|
||||
|
||||
class Settings:
|
||||
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"
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
# TODO: Add all models to backend_models
|
||||
from backend.services.payment.models import IncomeProfileDoc, PaymentProfileDoc
|
||||
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(user_models)
|
||||
# backend_models.extend(profile_models)
|
||||
|
||||
@ -2,6 +2,7 @@ from typing import List, Dict, Optional
|
||||
from decimal import Decimal
|
||||
from beanie import Document
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
from backend.services.payment.constants import PaymentGateway
|
||||
from backend.infra.payment.constants import MoneyCollectionType, PaymentLocation
|
||||
@ -23,7 +24,6 @@ class MoneyCollectingMethod(BaseModel):
|
||||
location: Optional[PaymentLocation]
|
||||
priority: int = 0 # less number has high priority to be used.
|
||||
stripe_account_id: Optional[str]
|
||||
wechat_qr_code: Optional[str]
|
||||
last_update_time: Optional[int] = None
|
||||
|
||||
|
||||
@ -61,3 +61,6 @@ class PaymentProfileDoc(Document):
|
||||
|
||||
class Settings:
|
||||
name = "payment_profile"
|
||||
|
||||
|
||||
|
||||
|
||||
@ -3,4 +3,3 @@ from enum import IntEnum
|
||||
|
||||
class PaymentGateway(IntEnum):
|
||||
STRIP = 1
|
||||
WECHAT = 2
|
||||
|
||||
@ -27,7 +27,6 @@ class MoneyCollectingMethod(BaseModel):
|
||||
location: Optional[PaymentLocation]
|
||||
priority: int = 0 # less number has high priority to be used.
|
||||
stripe_account_id: Optional[str]
|
||||
wechat_qr_code: Optional[str]
|
||||
last_update_time: Optional[int] = None
|
||||
|
||||
|
||||
|
||||
@ -6,19 +6,6 @@ from fastapi.encoders import jsonable_encoder
|
||||
router = APIRouter()
|
||||
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
|
||||
# Fetch stripe account id
|
||||
@router.get(
|
||||
|
||||
@ -209,3 +209,23 @@ async def handle_account_webhook(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
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)}"}
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user