feat: add tier_id FK to VendorSubscription for proper tier relationship

- Add tier_id column with FK to subscription_tiers table
- Add tier_obj relationship to VendorSubscription model
- Update tier_limits property to use database tier when available
- Create migration with SQLite batch mode support
- Backfill tier_id from existing tier code values

This enables proper database relationship between vendors and their
subscription tier, instead of just storing the tier code string.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 07:33:49 +01:00
parent 44de82eb47
commit 0ad54a52e0
3 changed files with 491 additions and 2 deletions

View File

@@ -0,0 +1,71 @@
"""Add tier_id FK to vendor_subscriptions
Revision ID: k9f0a1b2c3d4
Revises: 2953ed10d22c
Create Date: 2025-12-26
Adds tier_id column to vendor_subscriptions table with FK to subscription_tiers.
Backfills tier_id based on existing tier (code) values.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "k9f0a1b2c3d4"
down_revision = "2953ed10d22c"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Use batch mode for SQLite compatibility
with op.batch_alter_table("vendor_subscriptions", schema=None) as batch_op:
# Add tier_id column (nullable for backfill)
batch_op.add_column(
sa.Column("tier_id", sa.Integer(), nullable=True)
)
# Create index for tier_id
batch_op.create_index(
"ix_vendor_subscriptions_tier_id",
["tier_id"],
unique=False,
)
# Add FK constraint
batch_op.create_foreign_key(
"fk_vendor_subscriptions_tier_id",
"subscription_tiers",
["tier_id"],
["id"],
ondelete="SET NULL",
)
# Backfill tier_id from tier code
# This updates existing subscriptions to link to their tier
op.execute(
"""
UPDATE vendor_subscriptions
SET tier_id = (
SELECT id FROM subscription_tiers
WHERE subscription_tiers.code = vendor_subscriptions.tier
)
WHERE EXISTS (
SELECT 1 FROM subscription_tiers
WHERE subscription_tiers.code = vendor_subscriptions.tier
)
"""
)
def downgrade() -> None:
with op.batch_alter_table("vendor_subscriptions", schema=None) as batch_op:
# Drop FK constraint
batch_op.drop_constraint(
"fk_vendor_subscriptions_tier_id",
type_="foreignkey",
)
# Drop index
batch_op.drop_index("ix_vendor_subscriptions_tier_id")
# Drop column
batch_op.drop_column("tier_id")

View File

@@ -0,0 +1,401 @@
# Subscription Workflow Plan
## Overview
End-to-end subscription management workflow for vendors on the platform.
---
## 1. Vendor Subscribes to a Tier
### 1.1 New Vendor Registration Flow
```
Vendor Registration → Select Tier → Trial Period → Payment Setup → Active Subscription
```
**Steps:**
1. Vendor creates account (existing flow)
2. During onboarding, vendor selects a tier:
- Show tier comparison cards (Essential, Professional, Business, Enterprise)
- Highlight features and limits for each tier
- Default to 14-day trial on selected tier
3. Create `VendorSubscription` record with:
- `tier` = selected tier code
- `status` = "trial"
- `trial_ends_at` = now + 14 days
- `period_start` / `period_end` set for trial period
4. Before trial ends, prompt vendor to add payment method
5. On payment method added → Create Stripe subscription → Status becomes "active"
### 1.2 Database Changes Required
**Add FK relationship to `subscription_tiers`:**
```python
# VendorSubscription - Add proper FK
tier_id = Column(Integer, ForeignKey("subscription_tiers.id"), nullable=True)
tier_code = Column(String(20), nullable=False) # Keep for backwards compat
# Relationship
tier_obj = relationship("SubscriptionTier", backref="subscriptions")
```
**Migration:**
1. Add `tier_id` column (nullable initially)
2. Populate `tier_id` from existing `tier` code values
3. Add FK constraint
### 1.3 API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/vendor/subscription/tiers` | GET | List available tiers for selection |
| `/api/v1/vendor/subscription/select-tier` | POST | Select tier during onboarding |
| `/api/v1/vendor/subscription/setup-payment` | POST | Create Stripe checkout for payment |
---
## 2. Admin Views Subscription on Vendor Page
### 2.1 Vendor Detail Page Enhancement
**Location:** `/admin/vendors/{vendor_id}`
**New Subscription Card:**
```
┌─────────────────────────────────────────────────────────────┐
│ Subscription [Edit] │
├─────────────────────────────────────────────────────────────┤
│ Tier: Professional Status: Active │
│ Price: €99/month Since: Jan 15, 2025 │
│ Next Billing: Feb 15, 2025 │
├─────────────────────────────────────────────────────────────┤
│ Usage This Period │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Orders │ │ Products │ │ Team Members │ │
│ │ 234 / 500 │ │ 156 / ∞ │ │ 2 / 3 │ │
│ │ ████████░░ │ │ ████████████ │ │ ██████░░░░ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Add-ons: Custom Domain (mydomain.com), 5 Email Addresses │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Files to Modify
- `app/templates/admin/vendor-detail.html` - Add subscription card
- `static/admin/js/vendor-detail.js` - Load subscription data
- `app/api/v1/admin/vendors.py` - Include subscription in vendor response
### 2.3 Admin Quick Actions
From the vendor page, admin can:
- **Change Tier** - Upgrade/downgrade vendor
- **Override Limits** - Set custom limits (enterprise deals)
- **Extend Trial** - Give more trial days
- **Cancel Subscription** - With reason
- **Manage Add-ons** - Add/remove add-ons
---
## 3. Tier Upgrade/Downgrade
### 3.1 Admin-Initiated Change
**Location:** Admin vendor page → Subscription card → [Edit] button
**Modal: Change Subscription Tier**
```
┌─────────────────────────────────────────────────────────┐
│ Change Subscription Tier [X] │
├─────────────────────────────────────────────────────────┤
│ Current: Professional (€99/month) │
│ │
│ New Tier: │
│ ○ Essential (€49/month) - Downgrade │
│ ● Business (€199/month) - Upgrade │
│ ○ Enterprise (Custom) - Contact required │
│ │
│ When to apply: │
│ ○ Immediately (prorate current period) │
│ ● At next billing cycle (Feb 15, 2025) │
│ │
│ [ ] Notify vendor by email │
│ │
│ [Cancel] [Apply Change] │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Vendor-Initiated Change
**Location:** Vendor dashboard → Billing page → [Change Plan]
**Flow:**
1. Vendor clicks "Change Plan" on billing page
2. Shows tier comparison with current tier highlighted
3. Vendor selects new tier
4. For upgrades:
- Show prorated amount for immediate change
- Or option to change at next billing
- Redirect to Stripe checkout if needed
5. For downgrades:
- Always schedule for next billing cycle
- Show what features they'll lose
- Confirmation required
### 3.3 API Endpoints
| Endpoint | Method | Actor | Description |
|----------|--------|-------|-------------|
| `/api/v1/admin/subscriptions/{vendor_id}/change-tier` | POST | Admin | Change vendor's tier |
| `/api/v1/vendor/billing/change-tier` | POST | Vendor | Request tier change |
| `/api/v1/vendor/billing/preview-change` | POST | Vendor | Preview proration |
### 3.4 Stripe Integration
**Upgrade (Immediate):**
```python
stripe.Subscription.modify(
subscription_id,
items=[{"price": new_price_id}],
proration_behavior="create_prorations"
)
```
**Downgrade (Scheduled):**
```python
stripe.Subscription.modify(
subscription_id,
items=[{"price": new_price_id}],
proration_behavior="none",
billing_cycle_anchor="unchanged"
)
# Store scheduled change in our DB
```
---
## 4. Add-ons Upselling
### 4.1 Where Add-ons Are Displayed
#### A. Vendor Billing Page
```
/vendor/{code}/billing
┌─────────────────────────────────────────────────────────────┐
│ Available Add-ons │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🌐 Custom Domain │ │ 📧 Email Package │ │
│ │ €15/year │ │ From €5/month │ │
│ │ Use your own domain │ │ 5, 10, or 25 emails │ │
│ │ [Add to Plan] │ │ [Add to Plan] │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 🔒 Premium SSL │ │ 💾 Extra Storage │ │
│ │ €49/year │ │ €5/month per 10GB │ │
│ │ EV certificate │ │ More product images │ │
│ │ [Add to Plan] │ │ [Add to Plan] │ │
│ └─────────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
#### B. Contextual Upsells
**When vendor hits a limit:**
```
┌─────────────────────────────────────────────────────────┐
│ ⚠️ You've reached your order limit for this month │
│ │
│ Upgrade to Professional to get 500 orders/month │
│ [Upgrade Now] [Dismiss] │
└─────────────────────────────────────────────────────────┘
```
**In settings when configuring domain:**
```
┌─────────────────────────────────────────────────────────┐
│ 🌐 Custom Domain │
│ │
│ Your shop is available at: myshop.platform.com │
│ │
│ Want to use your own domain like www.myshop.com? │
│ Add the Custom Domain add-on for just €15/year │
│ │
│ [Add Custom Domain] │
└─────────────────────────────────────────────────────────┘
```
#### C. Upgrade Prompts in Tier Comparison
When showing tier comparison, highlight what add-ons come included:
- Professional: Includes 1 custom domain
- Business: Includes custom domain + 5 email addresses
- Enterprise: All add-ons included
### 4.2 Add-on Purchase Flow
```
Vendor clicks [Add to Plan]
Modal: Configure Add-on
- Domain: Enter domain name, check availability
- Email: Select package (5/10/25)
Create Stripe checkout session for add-on price
On success: Create VendorAddOn record
Provision add-on (domain registration, email setup)
```
### 4.3 Add-on Management
**Vendor can view/manage in Billing page:**
```
┌─────────────────────────────────────────────────────────────┐
│ Your Add-ons │
├─────────────────────────────────────────────────────────────┤
│ Custom Domain myshop.com €15/year [Manage] │
│ Email Package 5 addresses €5/month [Manage] │
│ │
│ Next billing: Feb 15, 2025 │
└─────────────────────────────────────────────────────────────┘
```
### 4.4 Database: `vendor_addons` Table
```python
class VendorAddOn(Base):
id = Column(Integer, primary_key=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"))
addon_product_id = Column(Integer, ForeignKey("addon_products.id"))
# Config (e.g., domain name, email count)
config = Column(JSON, nullable=True)
# Stripe
stripe_subscription_item_id = Column(String(100))
# Status
status = Column(String(20)) # active, cancelled, pending_setup
provisioned_at = Column(DateTime)
# Billing
quantity = Column(Integer, default=1)
created_at = Column(DateTime)
cancelled_at = Column(DateTime, nullable=True)
```
---
## 5. Implementation Phases
### Phase 1: Database & Core (Day 1-2)
- [ ] Add `tier_id` FK to VendorSubscription
- [ ] Create migration with data backfill
- [ ] Update subscription service to use tier relationship
- [ ] Update admin subscription endpoints
### Phase 2: Admin Vendor Page (Day 2-3)
- [ ] Add subscription card to vendor detail page
- [ ] Show usage meters (orders, products, team)
- [ ] Add "Edit Subscription" modal
- [ ] Implement tier change API (admin)
### Phase 3: Vendor Billing Page (Day 3-4)
- [ ] Create `/vendor/{code}/billing` page
- [ ] Show current plan and usage
- [ ] Add tier comparison/change UI
- [ ] Implement tier change API (vendor)
- [ ] Add Stripe checkout integration for upgrades
### Phase 4: Add-ons (Day 4-5)
- [ ] Seed add-on products in database
- [ ] Add "Available Add-ons" section to billing page
- [ ] Implement add-on purchase flow
- [ ] Create VendorAddOn management
- [ ] Add contextual upsell prompts
### Phase 5: Polish & Testing (Day 5-6)
- [ ] Email notifications for tier changes
- [ ] Webhook handling for Stripe events
- [ ] Usage limit enforcement updates
- [ ] End-to-end testing
- [ ] Documentation
---
## 6. Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `app/templates/vendor/billing.html` | Vendor billing page |
| `static/vendor/js/billing.js` | Billing page JS |
| `app/api/v1/vendor/billing.py` | Vendor billing endpoints |
| `app/services/addon_service.py` | Add-on management |
### Modified Files
| File | Changes |
|------|---------|
| `models/database/subscription.py` | Add tier_id FK |
| `app/templates/admin/vendor-detail.html` | Add subscription card |
| `static/admin/js/vendor-detail.js` | Load subscription data |
| `app/api/v1/admin/vendors.py` | Include subscription in response |
| `app/api/v1/admin/subscriptions.py` | Add tier change endpoint |
| `app/services/subscription_service.py` | Tier change logic |
| `app/templates/vendor/partials/sidebar.html` | Add Billing link |
---
## 7. API Summary
### Admin APIs
```
GET /admin/vendors/{id} # Includes subscription
POST /admin/subscriptions/{vendor_id}/change-tier
POST /admin/subscriptions/{vendor_id}/override-limits
POST /admin/subscriptions/{vendor_id}/extend-trial
POST /admin/subscriptions/{vendor_id}/cancel
```
### Vendor APIs
```
GET /vendor/billing/subscription # Current subscription
GET /vendor/billing/tiers # Available tiers
POST /vendor/billing/preview-change # Preview tier change
POST /vendor/billing/change-tier # Request tier change
POST /vendor/billing/checkout # Stripe checkout session
GET /vendor/billing/addons # Available add-ons
GET /vendor/billing/my-addons # Vendor's add-ons
POST /vendor/billing/addons/purchase # Purchase add-on
DELETE /vendor/billing/addons/{id} # Cancel add-on
```
---
## 8. Questions to Resolve
1. **Trial without payment method?**
- Allow full trial without card, or require card upfront?
2. **Downgrade handling:**
- What happens if vendor has more products than new tier allows?
- Block downgrade, or just prevent new products?
3. **Enterprise tier:**
- Self-service or contact sales only?
- Custom pricing in UI or hidden?
4. **Add-on provisioning:**
- Domain: Use reseller API or manual process?
- Email: Integrate with email provider or manual?
5. **Grace period:**
- How long after payment failure before suspension?
- What gets disabled first?

View File

@@ -431,7 +431,10 @@ class VendorSubscription(Base, TimestampMixin):
Integer, ForeignKey("vendors.id"), unique=True, nullable=False, index=True
)
# Tier
# Tier - tier_id is the FK, tier (code) kept for backwards compatibility
tier_id = Column(
Integer, ForeignKey("subscription_tiers.id"), nullable=True, index=True
)
tier = Column(
String(20), default=TierCode.ESSENTIAL.value, nullable=False, index=True
)
@@ -479,6 +482,7 @@ class VendorSubscription(Base, TimestampMixin):
# Relationships
vendor = relationship("Vendor", back_populates="subscription")
tier_obj = relationship("SubscriptionTier", backref="subscriptions")
__table_args__ = (
Index("idx_subscription_vendor_status", "vendor_id", "status"),
@@ -494,7 +498,20 @@ class VendorSubscription(Base, TimestampMixin):
@property
def tier_limits(self) -> dict:
"""Get the limit definitions for current tier."""
"""Get the limit definitions for current tier.
Uses database tier (tier_obj) if available, otherwise falls back
to hardcoded TIER_LIMITS for backwards compatibility.
"""
# Use database tier if relationship is loaded
if self.tier_obj is not None:
return {
"orders_per_month": self.tier_obj.orders_per_month,
"products_limit": self.tier_obj.products_limit,
"team_members": self.tier_obj.team_members,
"features": self.tier_obj.features or [],
}
# Fall back to hardcoded limits
return TIER_LIMITS.get(TierCode(self.tier), TIER_LIMITS[TierCode.ESSENTIAL])
@property