Fix Stripe charge settling: attach PM to customer on add, use CIT for subscription upgrades

When a card was added via billing page, it was only stored in the DB — never
attached to the Stripe customer. This meant every charge was on an unattached
PM, which Stripe processes differently.

For subscription upgrades the user is actively present, so off_session=True
was incorrect: it marks the charge as a Merchant-Initiated Transaction (MIT)
which settles slower than Customer-Initiated (CIT). Other sites that use
Stripe's frontend SDK do CITs, which is why their charges clear faster.

- dashboard_add_payment_method_stripe: attach PM to Stripe customer and set
  as customer default immediately when the card is added
- auto_charge: add off_session parameter (default True for existing auto
  top-up/renewal callers); document the distinction
- dashboard_subscribe_tier: pass off_session=False so the upgrade charge is
  processed as a CIT and settles at normal speed
parent f8c830a0
......@@ -269,23 +269,36 @@ class StripePaymentHandler:
logger.info(f"Wallet credited: user={user_id}, amount={amount}, intent={payment_intent['id']}")
async def auto_charge(self, user_id: int, amount: Decimal, payment_method_id: str,
description: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Charge a saved payment method immediately (off-session)."""
description: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
off_session: bool = True) -> Dict[str, Any]:
"""Charge a saved payment method immediately.
Use off_session=False when the customer is actively present (e.g. clicking
an upgrade button) so the charge is treated as a Customer-Initiated
Transaction and settles at normal speed. Use off_session=True only for
background charges (auto-renewals, auto top-ups) where the customer is
not present.
"""
try:
customer_id = await self._get_or_create_customer(user_id)
amount_cents = int(amount * 100)
payment_intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
intent_params = dict(
amount=amount_cents,
currency=self.config.get('currency_code', 'usd').lower(),
customer=customer_id,
payment_method=payment_method_id,
confirm=True,
off_session=True,
description=description or f'Charge: ${amount:.2f}',
metadata=metadata or {'user_id': str(user_id), 'amount': str(amount)}
)
if off_session:
intent_params['off_session'] = True
payment_intent = await asyncio.to_thread(
stripe.PaymentIntent.create,
**intent_params
)
if payment_intent.status not in ('succeeded', 'processing'):
logger.error(f"Unexpected PaymentIntent status for user {user_id}: {payment_intent.status} ({payment_intent.id})")
......
......@@ -9076,7 +9076,8 @@ async def dashboard_subscribe_tier(request: Request, tier_id: int):
Decimal(str(tier_price)),
default_method['identifier'],
description=f"Subscription upgrade to {target_tier['name']}",
metadata={'user_id': str(user_id), 'tier_id': str(tier_id), 'tier_name': target_tier['name'], 'amount': str(tier_price)}
metadata={'user_id': str(user_id), 'tier_id': str(tier_id), 'tier_name': target_tier['name'], 'amount': str(tier_price)},
off_session=False
)
if not result.get('success'):
return JSONResponse({"error": result.get('error', 'Card charge failed')}, status_code=402)
......@@ -9519,18 +9520,36 @@ async def dashboard_add_payment_method_stripe(request: Request):
auth_check = require_dashboard_auth(request)
if auth_check:
return auth_check
data = await request.json()
user_id = request.session.get('user_id')
payment_method_id = data.get('payment_method_id')
if not payment_method_id:
return JSONResponse({"success": False, "error": "Payment method ID required"}, status_code=400)
db = DatabaseRegistry.get_config_database()
# Store payment method in database
try:
# Attach the PM to the Stripe customer so it can be charged later
if payment_service and payment_service.stripe_handler:
customer_id = await payment_service.stripe_handler._get_or_create_customer(user_id)
import stripe as _stripe
import asyncio as _asyncio
try:
await _asyncio.to_thread(
_stripe.PaymentMethod.attach,
payment_method_id,
customer=customer_id
)
await _asyncio.to_thread(
_stripe.Customer.modify,
customer_id,
invoice_settings={'default_payment_method': payment_method_id}
)
except _stripe.error.InvalidRequestError as e:
if 'already been attached' not in str(e):
raise
method_id = db.add_payment_method(user_id, 'stripe', payment_method_id, is_default=True, metadata={'stripe_payment_method_id': payment_method_id})
return JSONResponse({"success": True, "message": "Credit card added successfully"})
except Exception as e:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment