Files
service-finder/plans/logic_spec_subscription_downgrade_and_mock_payment.md

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:

  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 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 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:

  1. Calculates price → Free tier has monthly_price: 0price = 0.0
  2. Calls _create_payment_intent() → passes amount=0.0 → calls PaymentRouter.create_payment_intent() 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() 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:

  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 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 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

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(): 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