# 🏗️ 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