make_post_sell/docs/tickets/mps-15.md

4.3 KiB

MPS-15: 21-Day Free Trial

Summary

New shops get a 21-day free trial. Trial includes 1 shop, 1 seat, all features. After 21 days the shop enters a grace period, then becomes read-only until a plan is chosen.

Model Changes

Shop model (models/shop.py)

Add columns:

trial_started_timestamp = Column(BigInteger, nullable=True)
trial_ended = Column(Boolean, default=False)
plan_active = Column(Boolean, default=False)
  • trial_started_timestamp: set to current time (ms) on shop creation
  • trial_ended: flipped to True when trial expires (background check or request-time check)
  • plan_active: True when a paid plan is active (billing integration, future ticket)

Migration

  • Add 3 columns to mps_shop with idempotent guards
  • trial_started_timestamp nullable (existing shops get NULL = pre-trial era, treated as paid)
  • trial_ended server_default="0"
  • plan_active server_default="0"

Properties

TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000  # 21 days in milliseconds

@property
def trial_expiry_timestamp(self):
    if self.trial_started_timestamp is None:
        return None
    return self.trial_started_timestamp + self.TRIAL_DURATION_MS

@property
def is_trial_active(self):
    if self.plan_active:
        return False  # paid plan supersedes trial
    if self.trial_started_timestamp is None:
        return False  # pre-trial shop (existing shops)
    now = int(time.time() * 1000)
    return now < self.trial_expiry_timestamp

@property
def is_trial_expired(self):
    if self.plan_active or self.trial_started_timestamp is None:
        return False
    now = int(time.time() * 1000)
    return now >= self.trial_expiry_timestamp

@property
def trial_days_remaining(self):
    if not self.is_trial_active:
        return 0
    remaining_ms = self.trial_expiry_timestamp - int(time.time() * 1000)
    return max(0, remaining_ms // (24 * 60 * 60 * 1000))

@property
def is_active(self):
    """Shop can operate: either paid plan or active trial."""
    return self.plan_active or self.is_trial_active

Trial Enforcement

What trial shops CAN do (all features)

  • Create products, upload files, set prices
  • Accept payments (all 5 methods)
  • Use watch mode, analytics, subscriptions, gift cards
  • Create 2 dev/stage shops (per MPS-14)
  • Full settings access

What happens when trial expires

  • Shop becomes read-only: products visible, downloads work for existing purchases
  • New purchases blocked (checkout disabled)
  • Product creation/editing disabled
  • Settings page shows "Trial expired — choose a plan to continue"
  • Flash message on every page: "Your 21-day trial has expired. Choose a plan to keep selling."

Enforcement points

Request-time check (middleware or request method):

def shop_trial_check(request):
    shop = request.shop
    if shop and shop.is_trial_expired and not shop.plan_active:
        # Allow read-only routes, block write routes
        ...

Write routes to block when trial expired:

  • Product create/edit/delete
  • Checkout completion (all 3 paths: Stripe, PayPal, Adyen)
  • Gift card purchase
  • Settings changes (except choosing a plan)

Read routes to allow:

  • Product view, shop view, search
  • Cart view (but not checkout)
  • Settings view (read-only, plan selection enabled)
  • Download (for existing purchases)

Trial Banner

In base.j2, show trial status for shop owners:

{% if request.shop and request.shop.is_trial_active and request.user in request.shop.owners %}
<div class="trial-banner">
  Free trial: {{ request.shop.trial_days_remaining }} days remaining.
  <a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan</a>
</div>
{% endif %}

{% if request.shop and request.shop.is_trial_expired and not request.shop.plan_active %}
<div class="trial-banner trial-expired">
  Your 21-day trial has expired.
  <a href="/s/{{ request.shop.id }}/settings#plan">Choose a plan to keep selling</a>
</div>
{% endif %}

Shop Creation Changes

In views/shop.py shop_new():

  • Set shop.trial_started_timestamp = int(time.time() * 1000) on creation
  • Existing shops (NULL timestamp) are grandfathered as paid

Tests

  • Unit: trial properties (is_trial_active, is_trial_expired, trial_days_remaining, is_active)
  • Integration: trial shop with real DB, verify expiry behavior
  • Functional: create shop, verify trial banner, verify trial countdown