# 🏗️ 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: 1. **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. 2. **Mock Payment Gateway Simulation**: Upgrade the existing [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) to 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`](backend/app/services/payment_router.py:62) | L62-63 | `if net_amount <= 0: raise ValueError(...)` | | **Billing API** | [`backend/app/api/v1/endpoints/billing.py`](backend/app/api/v1/endpoints/billing.py:70) | 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`](frontend_app/src/views/SubscriptionPlansView.vue:296) `handleOrder()`) calls: ```typescript 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`](backend/app/api/v1/endpoints/financial_manager.py:128), which: 1. **Calculates price** → Free tier has `monthly_price: 0` → `price = 0.0` 2. **Calls `_create_payment_intent()`** → passes `amount=0.0` → calls [`PaymentRouter.create_payment_intent()`](backend/app/services/payment_router.py:34) with `net_amount=0.0` 3. **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()`](backend/app/services/financial_manager.py:176) always creates a PaymentIntent, even for free ($0) tiers. The PaymentRouter requires `net_amount > 0`. ### 2.3 Current Flow Diagram (Broken) ```mermaid graph TD A[User clicks 'Megrendelés' on Free tier] --> B[SubscriptionPlansView.handleOrder] B --> C[POST /financial-manager/purchase-package
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
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): ```mermaid 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`](backend/app/models/core_logic.py:200) and [`OrganizationSubscription`](backend/app/models/core_logic.py:143) models: ```python # 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 ```mermaid 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:
current_subscription.valid_until < now
AND pending_tier_id IS NOT NULL] I --> J[Activate pending tier
Deactivate current
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: ```bash docker exec -it sf_api python -m app.scripts.sync_engine ``` #### Step 2: Model Update Update [`UserSubscription`](backend/app/models/core_logic.py:200-253) and [`OrganizationSubscription`](backend/app/models/core_logic.py:143-198) with new columns. #### Step 3: Service Logic — `SubscriptionService` Enhancement Add a new method to [`backend/app/services/subscription_service.py`](backend/app/services/subscription_service.py): ```python @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()`](backend/app/services/financial_manager.py:176), add a check after Step 1 (tier validation): ```python # 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`](backend/app/services/downgrade_executor.py) that: 1. Queries: `SELECT * FROM finance.user_subscriptions WHERE pending_tier_id IS NOT NULL AND valid_until < NOW()` 2. For each match: activate pending tier, deactivate current, clear pending fields 3. Same for `finance.org_subscriptions` 4. Logs all actions 5. Sends notification to user #### Step 6: Frontend — Notification The [`GET /subscriptions/my`](backend/app/api/v1/endpoints/subscriptions.py:229) endpoint already returns subscription data. Add: ```python 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`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) would show: ```html

📅 Csomagváltás folyamatban: {{ pendingTierName }} (hatályos: {{ formattedPendingDate }})

``` --- ## 5. 🛠️ Blueprint: Mock Payment Simulation Enhancement ### 5.1 Current State The existing [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) has three modes: - `auto_approve` (default): Instant success - `simulate_failure`: Always fails - `simulate_timeout`: Returns "processing" **Missing:** A realistic payment flow where: 1. A checkout URL/redirect is generated 2. The payment goes to "PENDING_PAYMENT" state 3. After an async callback (webhook), the payment is marked "PAID" ### 5.2 Desired Mock Payment Flow ```mermaid 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",
checkout_url: "http://mock-gateway/checkout/xxx",
intent_id: "mock_intent_xxx"} FM-->>F: {success: true,
checkout_url: "...",
status: "PENDING_PAYMENT"} Note over F: Frontend opens checkout_url
in new tab / embedded webview F->>GW: User clicks "Pay" on mock page GW->>FM: Simulated webhook callback
POST /billing/mock-webhook FM->>DB: Update PaymentIntent → COMPLETED FM->>SA: Activate subscription FM-->>F: {success: true,
redirect to success_url} end ``` ### 5.3 Implementation #### Step 1: Add `simulate_redirect` mode to MockPaymentGateway ```python 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 ```python 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`](backend/app/api/v1/endpoints/billing.py) — add new route): ```python @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""" Mock Payment Gateway

🪙 Mock Payment Gateway

Amount: {float(payment_intent.gross_amount)} {payment_intent.currency}

Intent: {intent_id}

""" return HTMLResponse(html) ``` **Mock Webhook Callback:** ```python @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()`](backend/app/services/financial_manager.py:176), add bypass logic: ```python # ── 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`](backend/app/models/core_logic.py:200) | Add `pending_tier_id`, `pending_activated_at` to `UserSubscription` | Low - new nullable columns | | 2 | [`backend/app/models/core_logic.py`](backend/app/models/core_logic.py:143) | Add same columns to `OrganizationSubscription` | Low - mirror change | ### Phase 2: Backend Services | # | File | Change | Risk | |---|------|--------|------| | 3 | [`backend/app/services/subscription_service.py`](backend/app/services/subscription_service.py:74) | Add `is_downgrade()` static method | Low | | 4 | [`backend/app/services/financial_manager.py`](backend/app/services/financial_manager.py:176) | Add free-tier bypass + downgrade detection path | Medium - core logic change | | 5 | [`backend/app/services/mock_payment_gateway.py`](backend/app/services/mock_payment_gateway.py:31) | 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`](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`](backend/app/api/v1/endpoints/subscriptions.py:229) | Add `has_pending_downgrade`, `pending_tier_name`, `pending_effective_date` to `/my` response | Low | | 9 | [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:148) | Add pending downgrade fields to `/auth/me` response | Low | ### Phase 4: Frontend | # | File | Change | Risk | |---|------|--------|------| | 10 | [`frontend_app/src/stores/auth.ts`](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`](frontend_app/src/components/dashboard/SubscriptionStatusWidget.vue) | Add pending downgrade banner | Low | | 12 | [`frontend_app/src/views/SubscriptionPlansView.vue`](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`](frontend_app/src/i18n/hu.ts) | Add `downgradePending`, `downgradeEffectiveDate`, `downgradeScheduled` keys | Low | | 14 | [`frontend_app/src/i18n/en.ts`](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()`: Verify `purchase_package()` with $0 price skips PaymentIntent creation - `test_mock_redirect_mode()`: Verify `create_intent()` returns `checkout_url` in redirect mode - `test_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_id` set on existing subscription - Full flow: User upgrades while pending → pending cleared, new tier immediate - Downgrade executor: Simulate `valid_until` in 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 - [`SubscriptionTier` model](backend/app/models/core_logic.py:68) — `system.subscription_tiers` with `tier_level` - [`UserSubscription` model](backend/app/models/core_logic.py:200) — `finance.user_subscriptions` - [`OrganizationSubscription` model](backend/app/models/core_logic.py:143) — `finance.org_subscriptions` - [`SubscriptionService`](backend/app/services/subscription_service.py:74) — Tier resolution logic - [`FinancialManager`](backend/app/services/financial_manager.py:141) — Purchase orchestrator - [`MockPaymentGateway`](backend/app/services/mock_payment_gateway.py:31) — Current mock gateway - [`PaymentRouter.create_payment_intent()`](backend/app/services/payment_router.py:34) — Where `net_amount` validation lives - [`PurchaseRequest` schema](backend/app/schemas/financial_manager.py:31) — API input schema - [`PurchaseResponse` schema](backend/app/schemas/financial_manager.py:48) — API response schema - [`SubscriptionPlansView.vue`](frontend_app/src/views/SubscriptionPlansView.vue:296) — Frontend `handleOrder()` function - [`PlanDetailsModal.vue`](frontend_app/src/components/subscription/PlanDetailsModal.vue) — Purchase modal - [P0 Subscription JSONB Audit](docs/p0_subscription_jsonb_structural_audit_report.md) — Tier JSONB structure verified - [logic_spec_subscription_card_upgrade.md](plans/logic_spec_subscription_card_upgrade.md) — Previous subscription card work