Fix Stripe charge status check, payment feedback, and modal HTML rendering

- auto_charge: verify payment_intent.status is succeeded/processing before returning success; add description/metadata params so subscription charges are labeled correctly in Stripe
- pricing: redirect to ?success= after upgrade/downgrade so the persistent server-side banner shows instead of a 1.5s toast that disappears on reload
- pricing GET endpoint: pass success/error query params to template context
- base modal: support html:true option in open() so showConfirm can render HTML content; update showConfirm signature to accept html flag
- pricing: pass html=true to showConfirm so the upgrade confirmation renders bold/colored text instead of raw tags
parent 45435f4b
...@@ -268,14 +268,13 @@ class StripePaymentHandler: ...@@ -268,14 +268,13 @@ class StripePaymentHandler:
) )
logger.info(f"Wallet credited: user={user_id}, amount={amount}, intent={payment_intent['id']}") 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) -> Dict[str, Any]: 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]:
Automatically charge a saved payment method for auto top up """Charge a saved payment method immediately (off-session)."""
"""
try: try:
customer_id = await self._get_or_create_customer(user_id) customer_id = await self._get_or_create_customer(user_id)
amount_cents = int(amount * 100) amount_cents = int(amount * 100)
payment_intent = await asyncio.to_thread( payment_intent = await asyncio.to_thread(
stripe.PaymentIntent.create, stripe.PaymentIntent.create,
amount=amount_cents, amount=amount_cents,
...@@ -284,23 +283,21 @@ class StripePaymentHandler: ...@@ -284,23 +283,21 @@ class StripePaymentHandler:
payment_method=payment_method_id, payment_method=payment_method_id,
confirm=True, confirm=True,
off_session=True, off_session=True,
description=f'Auto wallet top up: ${amount:.2f}', description=description or f'Charge: ${amount:.2f}',
metadata={ metadata=metadata or {'user_id': str(user_id), 'amount': str(amount)}
'user_id': str(user_id),
'topup': 'true',
'auto_topup': 'true',
'amount': str(amount)
}
) )
logger.info(f"Auto charge successful for user {user_id}: {payment_intent.id}") if payment_intent.status not in ('succeeded', 'processing'):
logger.error(f"Unexpected PaymentIntent status for user {user_id}: {payment_intent.status} ({payment_intent.id})")
return {"success": False, "error": f"Payment not completed (status: {payment_intent.status})"}
logger.info(f"Auto charge successful for user {user_id}: {payment_intent.id} status={payment_intent.status}")
return { return {
"success": True, "success": True,
"gateway_transaction_id": payment_intent.id, "gateway_transaction_id": payment_intent.id,
"amount": amount "amount": amount
} }
except stripe.error.CardError as e: except stripe.error.CardError as e:
logger.error(f"Auto charge card error for user {user_id}: {e.user_message}") logger.error(f"Auto charge card error for user {user_id}: {e.user_message}")
return {"success": False, "error": e.user_message} return {"success": False, "error": e.user_message}
......
...@@ -8946,6 +8946,8 @@ async def dashboard_pricing(request: Request): ...@@ -8946,6 +8946,8 @@ async def dashboard_pricing(request: Request):
"currency_symbol": currency_symbol, "currency_symbol": currency_symbol,
"wallet_balance": wallet_balance, "wallet_balance": wallet_balance,
"has_stripe_card": has_stripe_card, "has_stripe_card": has_stripe_card,
"success": request.query_params.get("success"),
"error": request.query_params.get("error"),
} }
) )
...@@ -9072,7 +9074,9 @@ async def dashboard_subscribe_tier(request: Request, tier_id: int): ...@@ -9072,7 +9074,9 @@ async def dashboard_subscribe_tier(request: Request, tier_id: int):
result = await payment_service.stripe_handler.auto_charge( result = await payment_service.stripe_handler.auto_charge(
user_id, user_id,
Decimal(str(tier_price)), Decimal(str(tier_price)),
default_method['identifier'] 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)}
) )
if not result.get('success'): if not result.get('success'):
return JSONResponse({"error": result.get('error', 'Card charge failed')}, status_code=402) return JSONResponse({"error": result.get('error', 'Card charge failed')}, status_code=402)
......
...@@ -1083,7 +1083,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -1083,7 +1083,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
function open(opts) { function open(opts) {
titleEl.textContent = opts.title || 'Notice'; titleEl.textContent = opts.title || 'Notice';
msgEl.textContent = opts.message || ''; if (opts.html) msgEl.innerHTML = opts.message || '';
else msgEl.textContent = opts.message || '';
if (opts.icon) { iconEl.className = 'ui-modal-icon ' + (opts.iconClass||'info'); iconEl.textContent = opts.icon; iconEl.style.display='block'; } if (opts.icon) { iconEl.className = 'ui-modal-icon ' + (opts.iconClass||'info'); iconEl.textContent = opts.icon; iconEl.style.display='block'; }
else { iconEl.style.display='none'; } else { iconEl.style.display='none'; }
inputEl.style.display = opts.prompt ? 'block' : 'none'; inputEl.style.display = opts.prompt ? 'block' : 'none';
...@@ -1113,10 +1114,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -1113,10 +1114,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
buttons: [{label:'OK', value:true, cls:'btn'}] }); buttons: [{label:'OK', value:true, cls:'btn'}] });
}); });
}; };
window.showConfirm = function(message, title, icon, iconClass) { window.showConfirm = function(message, title, icon, iconClass, html) {
return new Promise(res => { return new Promise(res => {
_resolve = res; _resolve = res;
open({ title: title||'Confirm', message, icon: icon||'⚠️', iconClass: iconClass||'warn', open({ title: title||'Confirm', message, html, icon: icon||'⚠️', iconClass: iconClass||'warn',
buttons: [ buttons: [
{label:'Cancel', value:false, cls:'btn-secondary'}, {label:'Cancel', value:false, cls:'btn-secondary'},
{label:'Confirm', value:true, cls:'btn'} {label:'Confirm', value:true, cls:'btn'}
......
...@@ -278,7 +278,7 @@ async function subscribeToTier(tierId, tierName, tierPrice) { ...@@ -278,7 +278,7 @@ async function subscribeToTier(tierId, tierName, tierPrice) {
msg += `<br><br><span style="color:#f87171;"><i class="fas fa-exclamation-triangle me-1"></i>Your wallet balance (${CURRENCY_SYMBOL}${WALLET_BALANCE.toFixed(2)}) is insufficient and no card is saved.</span><br><a href="/dashboard/wallet" style="color:#4a9eff;">Top up wallet</a> or <a href="/dashboard/billing/add-method" style="color:#4a9eff;">add a card</a> first.`; msg += `<br><br><span style="color:#f87171;"><i class="fas fa-exclamation-triangle me-1"></i>Your wallet balance (${CURRENCY_SYMBOL}${WALLET_BALANCE.toFixed(2)}) is insufficient and no card is saved.</span><br><a href="/dashboard/wallet" style="color:#4a9eff;">Top up wallet</a> or <a href="/dashboard/billing/add-method" style="color:#4a9eff;">add a card</a> first.`;
} }
const ok = await showConfirm(msg, 'Upgrade Plan'); const ok = await showConfirm(msg, 'Upgrade Plan', undefined, undefined, true);
if (!ok) return; if (!ok) return;
const btn = document.querySelector(`button[onclick*="subscribeToTier(${tierId}"]`); const btn = document.querySelector(`button[onclick*="subscribeToTier(${tierId}"]`);
...@@ -291,8 +291,8 @@ async function subscribeToTier(tierId, tierName, tierPrice) { ...@@ -291,8 +291,8 @@ async function subscribeToTier(tierId, tierName, tierPrice) {
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
if (result.success) { if (result.success) {
showToast(result.message || 'Plan upgraded successfully!', 'success'); const msg = encodeURIComponent(result.message || 'Plan upgraded successfully!');
setTimeout(() => window.location.reload(), 1500); window.location.href = `/dashboard/pricing?success=${msg}`;
} else if (result.error === 'insufficient_funds') { } else if (result.error === 'insufficient_funds') {
showToast( showToast(
`Insufficient funds. Wallet: ${CURRENCY_SYMBOL}${(result.wallet_balance||0).toFixed(2)}, needed: ${CURRENCY_SYMBOL}${(result.required||0).toFixed(2)}. ` + `Insufficient funds. Wallet: ${CURRENCY_SYMBOL}${(result.wallet_balance||0).toFixed(2)}, needed: ${CURRENCY_SYMBOL}${(result.required||0).toFixed(2)}. ` +
...@@ -322,8 +322,8 @@ async function downgradeToFree() { ...@@ -322,8 +322,8 @@ async function downgradeToFree() {
.then(r => r.json()) .then(r => r.json())
.then(result => { .then(result => {
if (result.success) { if (result.success) {
showToast(result.message || 'Downgraded to free plan', 'success'); const msg = encodeURIComponent(result.message || 'Downgraded to free plan');
setTimeout(() => window.location.reload(), 1500); window.location.href = `/dashboard/pricing?success=${msg}`;
} else { } else {
showToast(result.error || 'Error processing downgrade', 'danger'); showToast(result.error || 'Error processing downgrade', 'danger');
} }
......
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