pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
597
plans/logic_spec_subscription_downgrade_and_mock_payment.md
Normal file
597
plans/logic_spec_subscription_downgrade_and_mock_payment.md
Normal file
@@ -0,0 +1,597 @@
|
||||
# 🏗️ 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<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):
|
||||
|
||||
```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<br>- Simple query: `WHERE pending_tier_id IS NOT NULL`<br>- Easy migration<br>- Single source of truth for "what's active now" | - Slightly wider table |
|
||||
| **New `pending_subscriptions` table** | - Clean separation | - JOIN overhead<br>- Complexity of syncing two tables<br>- Risk of inconsistency |
|
||||
| **Separate inactive row with future date** | - Works with existing schema | - Hard to distinguish "pending" vs "expired"<br>- Conflicts with `is_active` logic |
|
||||
| **JSONB `pending_downgrade` field** | - No schema migration | - Not queryable via FK<br>- No referential integrity<br>- 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:<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:
|
||||
```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
|
||||
<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`](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", <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
|
||||
|
||||
```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"""
|
||||
<!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:**
|
||||
|
||||
```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
|
||||
Reference in New Issue
Block a user