25 KiB
🏗️ Logic Spec: Subscription Delayed Downgrade & Mock Payment Simulation
Date: 2026-07-28
Author: Fast Coder (Core Developer)
Status: 🔵 Blueprint — Ready for Architect/Code Hand-off
Masterbook Ref: Master Book 2.0 / Epic 3: Financial Motor / Subscription Packages
1. 🎯 Objective
Design and implement two interconnected features:
- Delayed Downgrade Mechanism: When a user switches to a cheaper/free tier, the change must only take effect after the current, already-paid subscription period expires. The user must be notified about this pending change.
- Mock Payment Gateway Simulation: Upgrade the existing
MockPaymentGatewayto properly simulate a full payment lifecycle (redirect to financial institution → async webhook callback → order completion), not just instant auto-approve.
2. 🔍 Root Cause Analysis: net_amount pozitív szám kell legyen
2.1 Error Source Pinpointed
The error originates from two locations:
| Location | File | Line | Trigger |
|---|---|---|---|
| Payment Router | backend/app/services/payment_router.py |
L62-63 | if net_amount <= 0: raise ValueError(...) |
| Billing API | backend/app/api/v1/endpoints/billing.py |
L70-71 | if net_amount is None or net_amount <= 0: raise HTTPException(400, ...) |
2.2 Why It Happens for Free Tier Switch
The current flow (via SubscriptionPlansView.vue handleOrder()) calls:
await api.post('/financial-manager/purchase-package', {
tier_id: plan.id, // e.g., Free tier (id=5)
org_id: orgId,
region_code: 'GLOBAL',
currency: 'EUR',
})
This goes to POST /financial-manager/purchase-package, which:
- Calculates price → Free tier has
monthly_price: 0→price = 0.0 - Calls
_create_payment_intent()→ passesamount=0.0→ callsPaymentRouter.create_payment_intent()withnet_amount=0.0 - Validation fails at L62:
if net_amount <= 0: raise ValueError("net_amount pozitív szám kell legyen")
The core issue: The FinancialManager.purchase_package() always creates a PaymentIntent, even for free ($0) tiers. The PaymentRouter requires net_amount > 0.
2.3 Current Flow Diagram (Broken)
graph TD
A[User clicks 'Megrendelés' on Free tier] --> B[SubscriptionPlansView.handleOrder]
B --> C[POST /financial-manager/purchase-package<br/>tier_id: 5, currency: EUR]
C --> D[FinancialManager.purchase_package]
D --> E[1. Validate tier ✓]
E --> F[2. Calculate price → 0.0]
F --> G[3. Create PaymentIntent<br/>net_amount=0.0]
G --> H[PaymentRouter.create_payment_intent]
H --> I{net_amount <= 0?}
I -->|YES| J[ValueError: net_amount pozitív szám kell legyen]
J --> K[PurchaseResult(success=False)]
K --> L[HTTP 400]
L --> M[Frontend shows alert with error]
3. 📐 Architecture: Current Purchase Flow (Working)
3.1 Full Flow for Paid Tier (Successful Case)
When a user buys a paid tier (e.g., Premium at €12.99):
sequenceDiagram
participant F as Frontend
participant FM as FinancialManager
participant PR as PaymentRouter
participant GW as MockPaymentGateway
participant SA as SubscriptionActivator
participant DB as Database
F->>FM: POST /purchase-package {tier_id: 16}
FM->>DB: 1. Validate tier exists
FM->>FM: 2. Calculate price (PricingCalculator)
Note over FM: Extracts from tier.rules.pricing_zones[DEFAULT].monthly_price
FM->>PR: 3. Create PaymentIntent (net_amount=12.99)
PR->>DB: INSERT PaymentIntent (PENDING)
FM->>GW: 4. process payment (create_intent)
GW-->>FM: {id: "mock_intent_xxx", status: "completed"}
FM->>DB: Mark PaymentIntent → COMPLETED
FM->>SA: 5. Activate subscription
SA->>DB: Deactivate old subscription(s)
SA->>DB: INSERT new User/OrgSubscription
FM->>DB: 6. Distribute commission
FM-->>F: PurchaseResult {success: true, ...}
3.2 Database Tables Touched
| Table | Schema | Operation | When |
|---|---|---|---|
subscription_tiers |
system |
SELECT (validate tier) | Step 1 |
payment_intents |
marketplace |
INSERT (PENDING) → UPDATE (COMPLETED) | Steps 3-4 |
user_subscriptions / org_subscriptions |
finance |
UPDATE is_active=False (old) + INSERT (new) | Step 5 |
financial_ledger |
finance |
INSERT (if real payment) | Step 4 |
users |
identity |
UPDATE subscription_plan, subscription_expires_at | Step 5 |
4. 🛠️ Blueprint: Delayed Downgrade Mechanism
4.1 Design Decision: The pending_plan_id Column Approach
Strategy: Add two columns to both UserSubscription and OrganizationSubscription models:
# New columns to add:
pending_tier_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("system.subscription_tiers.id"), nullable=True,
comment="If set, this is a pending downgrade that activates after current period expires"
)
pending_activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When the pending downgrade was requested (for audit trail)"
)
Why this approach over alternatives:
| Approach | Pros | Cons |
|---|---|---|
| pending_tier_id on existing subscription (✅ Selected) | - No new table needed - Simple query: WHERE pending_tier_id IS NOT NULL- Easy migration - Single source of truth for "what's active now" |
- Slightly wider table |
New pending_subscriptions table |
- Clean separation | - JOIN overhead - Complexity of syncing two tables - Risk of inconsistency |
| Separate inactive row with future date | - Works with existing schema | - Hard to distinguish "pending" vs "expired" - Conflicts with is_active logic |
JSONB pending_downgrade field |
- No schema migration | - Not queryable via FK - No referential integrity - Anti-pattern for relational data |
4.2 Business Logic Flow
graph TD
A[User switches to cheaper/free plan] --> B{Compare tier_level}
B -->|New tier_level >= Current tier_level| C[UPGRADE: Activate immediately]
B -->|New tier_level < Current tier_level| D[DOWNGRADE: Set pending_tier_id]
D --> E[Keep current subscription active until valid_until]
D --> F[Return response: 'Downgrade scheduled for DATE']
C --> G[Standard FinancialManager flow]
G --> H[Deactivate old + Activate new immediately]
E --> I[CRON / Middleware check:<br/>current_subscription.valid_until < now<br/>AND pending_tier_id IS NOT NULL]
I --> J[Activate pending tier<br/>Deactivate current<br/>Clear pending_tier_id]
J --> K[Notify user via notification service]
4.3 Implementation Steps
Step 1: Database Migration (Alembic)
Add pending_tier_id (FK → system.subscription_tiers.id) and pending_activated_at (DateTime) to both finance.user_subscriptions and finance.org_subscriptions.
Since we use the Custom Sync Engine, run:
docker exec -it sf_api python -m app.scripts.sync_engine
Step 2: Model Update
Update UserSubscription and OrganizationSubscription with new columns.
Step 3: Service Logic — SubscriptionService Enhancement
Add a new method to backend/app/services/subscription_service.py:
@staticmethod
async def is_downgrade(
db: AsyncSession,
user_id: int,
target_tier_id: int,
active_org_id: Optional[int] = None,
) -> bool:
"""
Determine if switching to target_tier is a downgrade.
A downgrade occurs when:
- target_tier.tier_level < current_tier.tier_level
Returns True if this is a downgrade (delayed), False if upgrade (immediate).
"""
# Get current tier level
current_tier_name = await SubscriptionService.get_user_tier(db, user_id)
current_level = SubscriptionService._get_user_level(current_tier_name)
# Get target tier level
target_stmt = select(SubscriptionTier.tier_level).where(SubscriptionTier.id == target_tier_id)
target_result = await db.execute(target_stmt)
target_level = target_result.scalar_one_or_none()
if target_level is None:
raise ValueError(f"Tier {target_tier_id} not found")
return target_level < current_level
Step 4: FinancialManager — Downgrade Path in purchase_package()
In FinancialManager.purchase_package(), add a check after Step 1 (tier validation):
# After tier validation (Step 1)
is_downgrade = await SubscriptionService.is_downgrade(
db, user_id, tier_id, org_id
)
if is_downgrade:
# ── Delayed downgrade path ──
# Skip payment (downgrade is free / $0)
# Set pending_tier_id on the current active subscription
# Return success with pending=True flag
...
Step 5: Downgrade Executor — CRON / Background Task
Create a new service backend/app/services/downgrade_executor.py that:
- Queries:
SELECT * FROM finance.user_subscriptions WHERE pending_tier_id IS NOT NULL AND valid_until < NOW() - For each match: activate pending tier, deactivate current, clear pending fields
- Same for
finance.org_subscriptions - Logs all actions
- Sends notification to user
Step 6: Frontend — Notification
The GET /subscriptions/my endpoint already returns subscription data. Add:
response_data["has_pending_downgrade"] = pending_tier_id is not None
response_data["pending_tier_name"] = pending_tier.name if pending_tier else None
response_data["pending_effective_date"] = current_subscription.valid_until.isoformat()
The frontend SubscriptionStatusWidget.vue would show:
<div v-if="hasPendingDowngrade" class="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p class="text-amber-800 text-sm font-medium">
📅 Csomagváltás folyamatban: {{ pendingTierName }}
(hatályos: {{ formattedPendingDate }})
</p>
</div>
5. 🛠️ Blueprint: Mock Payment Simulation Enhancement
5.1 Current State
The existing MockPaymentGateway has three modes:
auto_approve(default): Instant successsimulate_failure: Always failssimulate_timeout: Returns "processing"
Missing: A realistic payment flow where:
- A checkout URL/redirect is generated
- The payment goes to "PENDING_PAYMENT" state
- After an async callback (webhook), the payment is marked "PAID"
5.2 Desired Mock Payment Flow
sequenceDiagram
participant F as Frontend
participant FM as FinancialManager
participant GW as MockPaymentGateway
F->>FM: POST /purchase-package {tier_id, ...}
FM->>GW: create_intent(amount=12.99)
alt amount == 0 (Free tier)
GW-->>FM: {status: "approved", amount: 0}
FM->>FM: Activate subscription immediately
FM-->>F: {success: true, amount_paid: 0}
else amount > 0 (Paid tier - NEW simulate mode)
GW-->>FM: {status: "requires_action", <br/>checkout_url: "http://mock-gateway/checkout/xxx", <br/>intent_id: "mock_intent_xxx"}
FM-->>F: {success: true, <br/>checkout_url: "...", <br/>status: "PENDING_PAYMENT"}
Note over F: Frontend opens checkout_url<br/>in new tab / embedded webview
F->>GW: User clicks "Pay" on mock page
GW->>FM: Simulated webhook callback<br/>POST /billing/mock-webhook
FM->>DB: Update PaymentIntent → COMPLETED
FM->>SA: Activate subscription
FM-->>F: {success: true, <br/>redirect to success_url}
end
5.3 Implementation
Step 1: Add simulate_redirect mode to MockPaymentGateway
def __init__(self, mode: str = "simulate_redirect", ...):
"""
Modes:
- simulate_redirect (NEW default): Returns checkout_url,
then processes via async webhook.
- auto_approve: Instant success (legacy).
- simulate_failure: Always fails.
"""
Step 2: Enhanced create_intent() for simulate_redirect
async def create_intent(self, amount, currency, metadata, **kwargs):
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
logger.info(
"[MOCK_PAYMENT_REQUEST] Initiating transaction with external gateway "
"for amount: %s %s, intent_id=%s",
amount, currency, intent_id,
)
if self.mode == "simulate_redirect" and amount > 0:
# Return a checkout URL that the frontend can redirect to
base_url = kwargs.get("base_url", "http://localhost:8000")
return {
"id": intent_id,
"status": "requires_action",
"amount": float(amount),
"currency": currency,
"checkout_url": f"{base_url}/mock-payment/checkout/{intent_id}",
"method": "GET",
"gateway": "mock",
"metadata": metadata or {},
}
# ... existing logic for other modes ...
Step 3: Mock Checkout Page & Webhook Endpoint
Mock Checkout Page (backend/app/api/v1/endpoints/billing.py — add new route):
@router.get("/mock-payment/checkout/{intent_id}")
async def mock_checkout_page(
intent_id: str,
db: AsyncSession = Depends(get_db),
):
"""
Mock payment checkout page.
Shows a simple HTML page with a "Pay Now" button.
When clicked, triggers the mock webhook callback.
"""
# Look up the PaymentIntent by stripe_session_id (= intent_id)
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
return HTMLResponse("Payment not found", status_code=404)
# Simple HTML form that POSTs to the mock success callback
html = f"""
<!DOCTYPE html>
<html>
<head><title>Mock Payment Gateway</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>🪙 Mock Payment Gateway</h1>
<div style="border: 1px solid #ddd; padding: 30px; max-width: 400px; margin: 0 auto;">
<p>Amount: <strong>{float(payment_intent.gross_amount)} {payment_intent.currency}</strong></p>
<p>Intent: {intent_id}</p>
<form action="/api/v1/billing/mock-payment/callback" method="POST">
<input type="hidden" name="intent_id" value="{intent_id}">
<button type="submit" style="padding: 12px 40px; background: #22c55e;
color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;">
✅ Pay Now
</button>
</form>
</div>
</body>
</html>
"""
return HTMLResponse(html)
Mock Webhook Callback:
@router.post("/mock-payment/callback")
async def mock_payment_callback(
intent_id: str = Form(...),
db: AsyncSession = Depends(get_db),
):
"""
Mock payment gateway callback.
Simulates the webhook that a real payment gateway would send
after successful payment. Updates PaymentIntent to COMPLETED
and triggers subscription activation.
"""
logger.info(
"[MOCK_PAYMENT_CALLBACK] Received callback for intent: %s",
intent_id,
)
# Look up the PaymentIntent
stmt = select(PaymentIntent).where(
PaymentIntent.stripe_session_id == intent_id,
PaymentIntent.status == PaymentIntentStatus.PENDING,
)
result = await db.execute(stmt)
payment_intent = result.scalar_one_or_none()
if not payment_intent:
return JSONResponse(
{"error": "PaymentIntent not found or not PENDING"},
status_code=404,
)
# Update status to COMPLETED
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.completed_at = datetime.utcnow()
# Activate the subscription (if we have metadata about the tier)
metadata = payment_intent.metadata or {}
tier_id = metadata.get("tier_id")
user_id = metadata.get("user_id")
if tier_id and user_id:
activator = SubscriptionActivator()
await activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id,
)
await db.commit()
logger.info(
"[MOCK_PAYMENT_CALLBACK] Payment completed: intent=%s, amount=%s",
intent_id, float(payment_intent.gross_amount),
)
# Return a redirect to the frontend success page
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/dashboard/subscription?payment=success",
status_code=302,
)
Step 4: Update FinancialManager for Free Tier ($0 bypass)
In FinancialManager.purchase_package(), add bypass logic:
# ── Step 2b: If price is 0 (Free tier), bypass payment entirely ──
if price <= 0:
logger.info(
"Free tier detected (price=0). Bypassing payment for user_id=%d tier_id=%d",
user_id, tier_id,
)
# Activate subscription directly (no PaymentIntent needed)
if org_id:
subscription = await self.subscription_activator.activate_org_subscription(
db=db, org_id=org_id, tier_id=tier_id, duration_days=duration_days,
)
is_org = True
else:
subscription = await self.subscription_activator.activate_user_subscription(
db=db, user_id=user_id, tier_id=tier_id, duration_days=duration_days,
)
is_org = False
await db.commit()
return PurchaseResult(
success=True,
subscription_id=subscription.id,
tier_name=tier.name,
valid_from=subscription.valid_from,
valid_until=subscription.valid_until,
amount_paid=0,
currency=currency,
gateway="none",
is_org_subscription=is_org,
)
6. 📋 Files Changed Summary
Phase 1: Database Schema
| # | File | Change | Risk |
|---|---|---|---|
| 1 | backend/app/models/core_logic.py |
Add pending_tier_id, pending_activated_at to UserSubscription |
Low - new nullable columns |
| 2 | backend/app/models/core_logic.py |
Add same columns to OrganizationSubscription |
Low - mirror change |
Phase 2: Backend Services
| # | File | Change | Risk |
|---|---|---|---|
| 3 | backend/app/services/subscription_service.py |
Add is_downgrade() static method |
Low |
| 4 | backend/app/services/financial_manager.py |
Add free-tier bypass + downgrade detection path | Medium - core logic change |
| 5 | backend/app/services/mock_payment_gateway.py |
Add simulate_redirect mode with checkout URL |
Low |
| 6 | NEW backend/app/services/downgrade_executor.py |
Cron-based pending downgrade activator | Medium - new service |
Phase 3: API Endpoints
| # | File | Change | Risk |
|---|---|---|---|
| 7 | backend/app/api/v1/endpoints/billing.py |
Add GET /mock-payment/checkout/{intent_id} + POST /mock-payment/callback |
Low - new endpoints |
| 8 | backend/app/api/v1/endpoints/subscriptions.py |
Add has_pending_downgrade, pending_tier_name, pending_effective_date to /my response |
Low |
| 9 | backend/app/api/v1/endpoints/users.py |
Add pending downgrade fields to /auth/me response |
Low |
Phase 4: Frontend
| # | File | Change | Risk |
|---|---|---|---|
| 10 | frontend_app/src/stores/auth.ts |
Add has_pending_downgrade, pending_tier_name, pending_effective_date to UserProfile |
Low |
| 11 | frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue |
Add pending downgrade banner | Low |
| 12 | frontend_app/src/views/SubscriptionPlansView.vue |
Show "Downgrade scheduled" message after successful order | Low |
Phase 5: I18n
| # | File | Change | Risk |
|---|---|---|---|
| 13 | frontend_app/src/i18n/hu.ts |
Add downgradePending, downgradeEffectiveDate, downgradeScheduled keys |
Low |
| 14 | frontend_app/src/i18n/en.ts |
Add English translations | Low |
7. ⚠️ Risk Assessment & Edge Cases
| Risk | Impact | Mitigation |
|---|---|---|
| Free tier bypass forgets to update User.subscription_plan | Stale data on auth/me | Explicitly update both UserSubscription AND User.subscription_plan field |
| Downgrade executor runs multiple times for same record | Duplicate activation race condition | Use FOR UPDATE SKIP LOCKED or is_active=False check + atomic UPDATE with WHERE pending_tier_id IS NOT NULL |
| User upgrades while pending downgrade exists | Conflicting pending state | On upgrade: clear pending_tier_id, activate new tier immediately |
| User cancels account before pending downgrade activates | Orphan pending record | pending_tier_id is a nullable FK → ON DELETE SET NULL (or just leave it, no harm) |
| Mock webhook never called (user closes browser) | PaymentIntent stuck in PENDING | Add expiry to PaymentIntent → cron marks as FAILED after 24h |
| $0 but with add-ons | Free tier bypass skips add-on handling | Check active_addons and handle separately |
| Stacking + downgrade conflict | User with stacked time remaining | Downgrade takes effect after ALL stacked time expires (valid_until is the definitive date) |
8. 🧪 Testing Strategy
Unit Tests
test_is_downgrade(): Verify tier_level comparison (free→premium = upgrade, premium→free = downgrade)test_free_tier_bypass(): Verifypurchase_package()with $0 price skips PaymentIntent creationtest_mock_redirect_mode(): Verifycreate_intent()returnscheckout_urlin redirect modetest_mock_webhook_callback(): Verify callback updates PaymentIntent to COMPLETED
Integration Tests
- Full flow: User buys Premium → immediately activated
- Full flow: User switches to Free →
pending_tier_idset on existing subscription - Full flow: User upgrades while pending → pending cleared, new tier immediate
- Downgrade executor: Simulate
valid_untilin past → verify pending tier activates
E2E Tests
- Frontend: Verify pending downgrade banner appears in SubscriptionStatusWidget
- Frontend: Verify "Csomagváltás folyamatban" message after successful downgrade order
9. 📎 References
SubscriptionTiermodel —system.subscription_tierswithtier_levelUserSubscriptionmodel —finance.user_subscriptionsOrganizationSubscriptionmodel —finance.org_subscriptionsSubscriptionService— Tier resolution logicFinancialManager— Purchase orchestratorMockPaymentGateway— Current mock gatewayPaymentRouter.create_payment_intent()— Wherenet_amountvalidation livesPurchaseRequestschema — API input schemaPurchaseResponseschema — API response schemaSubscriptionPlansView.vue— FrontendhandleOrder()functionPlanDetailsModal.vue— Purchase modal- P0 Subscription JSONB Audit — Tier JSONB structure verified
- logic_spec_subscription_card_upgrade.md — Previous subscription card work