diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py
index 2a85317..6ee0098 100644
--- a/make_post_sell/lib/mail.py
+++ b/make_post_sell/lib/mail.py
@@ -200,7 +200,7 @@ def send_purchase_email(request, to_email, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '
'.format(
- request.app["bucket.secure_uploads.get_endpoint"],
+ request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)
@@ -239,7 +239,7 @@ def send_sale_email(request, shop, products, total_cost):
thumbnail = ""
if "thumbnail1" in p.extensions:
thumbnail = '
'.format(
- request.app["bucket.secure_uploads.get_endpoint"],
+ request.shop_cdn_endpoint,
p.s3_path,
p.updated_timestamp,
)
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index 827b40c..1d04f39 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -166,6 +166,23 @@ class Shop(RBase, Base):
gift_card_min_in_cents = Column(BigInteger, nullable=False, default=500)
gift_card_max_in_cents = Column(BigInteger, nullable=False, default=25000)
+ # Shop environment: 0=production, 1=staging, 2=development
+ environment = Column(BigInteger, nullable=False, default=0)
+
+ # Trial and billing
+ trial_started_timestamp = Column(BigInteger, nullable=True)
+ trial_ended = Column(Boolean, default=False)
+ plan_active = Column(Boolean, default=False)
+
+ # Primary S3 bucket (Bring Your Own Bucket)
+ primary_s3_endpoint = Column(Unicode(256), nullable=True)
+ primary_s3_region = Column(Unicode(64), nullable=True)
+ primary_s3_bucket = Column(Unicode(128), nullable=True)
+ primary_s3_access_key = Column(Unicode(128), nullable=True)
+ primary_s3_secret_key = Column(Unicode(128), nullable=True)
+ primary_s3_cdn_endpoint = Column(Unicode(256), nullable=True)
+ primary_s3_enabled = Column(Boolean, default=False)
+
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)
@@ -248,6 +265,94 @@ class Shop(RBase, Base):
us.role_id = role_id
return us
+ # --- Environment properties (MPS-14) ---
+
+ @property
+ def is_production(self):
+ return self.environment == 0
+
+ @property
+ def is_staging(self):
+ return self.environment == 1
+
+ @property
+ def is_development(self):
+ return self.environment == 2
+
+ @property
+ def is_non_production(self):
+ return self.environment != 0
+
+ @property
+ def environment_label(self):
+ return {0: "Production", 1: "Staging", 2: "Development"}.get(
+ self.environment, "Production"
+ )
+
+ # --- Trial properties (MPS-15) ---
+
+ TRIAL_DURATION_MS = 21 * 24 * 60 * 60 * 1000 # 21 days
+
+ @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
+ if self.trial_started_timestamp is None:
+ return False
+ import time
+ 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
+ import time
+ 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
+ import time
+ 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, active trial, or pre-trial (existing)."""
+ if self.plan_active:
+ return True
+ if self.trial_started_timestamp is None:
+ return True # grandfathered pre-trial shop
+ return self.is_trial_active
+
+ # --- BYOB properties (MPS-16) ---
+
+ @property
+ def has_primary_s3(self):
+ return bool(
+ self.primary_s3_enabled
+ and self.primary_s3_endpoint
+ and self.primary_s3_bucket
+ and self.primary_s3_access_key
+ and self.primary_s3_secret_key
+ and self.primary_s3_cdn_endpoint
+ )
+
+ @property
+ def media_cdn_endpoint(self):
+ if self.has_primary_s3:
+ return self.primary_s3_cdn_endpoint
+ return None
+
@property
def has_s3_mirror(self):
"""Return True if shop has S3 mirror bucket configured and enabled."""
diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py
index f75293b..e4d5cce 100644
--- a/make_post_sell/request_methods.py
+++ b/make_post_sell/request_methods.py
@@ -155,6 +155,35 @@ def includeme(config):
aws_secret_access_key=request.app["bucket.secure_uploads.secret_key"],
)
+ def add_shop_uploads_client(request):
+ """Return S3 client for the current shop (BYOB or MPS default)."""
+ shop = request.shop
+ if shop and shop.has_primary_s3:
+ import boto3
+ session = boto3.session.Session()
+ return session.client(
+ "s3",
+ region_name=shop.primary_s3_region,
+ endpoint_url=shop.primary_s3_endpoint,
+ aws_access_key_id=shop.primary_s3_access_key,
+ aws_secret_access_key=shop.primary_s3_secret_key,
+ )
+ return request.secure_uploads_client
+
+ def add_shop_bucket_name(request):
+ """Return the bucket name for the current shop."""
+ shop = request.shop
+ if shop and shop.has_primary_s3:
+ return shop.primary_s3_bucket
+ return request.app["bucket.secure_uploads"]
+
+ def add_shop_cdn_endpoint(request):
+ """Return the CDN endpoint for the current shop's media."""
+ shop = request.shop
+ if shop and shop.has_primary_s3:
+ return shop.primary_s3_cdn_endpoint
+ return request.app["bucket.secure_uploads.get_endpoint"]
+
def add_is_shop_domain(request):
"""
Returns True or False.
@@ -373,6 +402,11 @@ def includeme(config):
add_secure_uploads_client, "secure_uploads_client", reify=True
)
+ # BYOB: shop-aware S3 client and bucket
+ config.add_request_method(add_shop_uploads_client, "shop_uploads_client", reify=True)
+ config.add_request_method(add_shop_bucket_name, "shop_bucket_name", reify=True)
+ config.add_request_method(add_shop_cdn_endpoint, "shop_cdn_endpoint", reify=True)
+
# Payment method checks
config.add_request_method(add_stripe_enabled, "stripe_enabled", reify=True)
config.add_request_method(
diff --git a/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py b/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py
new file mode 100644
index 0000000..e2b71ef
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/9884324a48e3_add_environment_trial_and_primary_s3_.py
@@ -0,0 +1,109 @@
+"""add environment trial and primary s3 columns to shop
+
+Revision ID: 9884324a48e3
+Revises: f8201a9ba045
+Create Date: 2026-03-07 17:49:35.847633
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9884324a48e3'
+down_revision = 'f8201a9ba045'
+branch_labels = None
+depends_on = None
+
+from make_post_sell.models.meta import UUIDType
+
+
+def _column_exists(table, column):
+ conn = op.get_bind()
+ result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
+ return any(row[1] == column for row in result.fetchall())
+
+
+def upgrade():
+ # MPS-14: Shop environment
+ if not _column_exists("mps_shop", "environment"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("environment", sa.BigInteger(), nullable=False, server_default="0"),
+ )
+
+ # MPS-15: Trial and billing
+ if not _column_exists("mps_shop", "trial_started_timestamp"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("trial_started_timestamp", sa.BigInteger(), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "trial_ended"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("trial_ended", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+ if not _column_exists("mps_shop", "plan_active"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("plan_active", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+ # MPS-16: Primary S3 bucket (BYOB)
+ if not _column_exists("mps_shop", "primary_s3_endpoint"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_endpoint", sa.Unicode(256), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_region"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_region", sa.Unicode(64), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_bucket"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_bucket", sa.Unicode(128), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_access_key"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_access_key", sa.Unicode(128), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_secret_key"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_secret_key", sa.Unicode(128), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_cdn_endpoint"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_cdn_endpoint", sa.Unicode(256), nullable=True),
+ )
+
+ if not _column_exists("mps_shop", "primary_s3_enabled"):
+ op.add_column(
+ "mps_shop",
+ sa.Column("primary_s3_enabled", sa.Boolean(), nullable=False, server_default="0"),
+ )
+
+
+def downgrade():
+ op.drop_column("mps_shop", "primary_s3_enabled")
+ op.drop_column("mps_shop", "primary_s3_cdn_endpoint")
+ op.drop_column("mps_shop", "primary_s3_secret_key")
+ op.drop_column("mps_shop", "primary_s3_access_key")
+ op.drop_column("mps_shop", "primary_s3_bucket")
+ op.drop_column("mps_shop", "primary_s3_region")
+ op.drop_column("mps_shop", "primary_s3_endpoint")
+ op.drop_column("mps_shop", "plan_active")
+ op.drop_column("mps_shop", "trial_ended")
+ op.drop_column("mps_shop", "trial_started_timestamp")
+ op.drop_column("mps_shop", "environment")
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 7f893ba..4b3a489 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -497,6 +497,40 @@ section.main {
overflow-x: hidden;
}
+.environment-banner {
+ display: grid;
+ align-items: center;
+ text-align: center;
+ font-weight: bold;
+ font-size: var(--font-size-sm);
+ padding: var(--space-2) var(--space-4);
+ color: var(--color-white);
+ background-color: var(--color-warning, #e6a700);
+}
+
+.environment-banner-development {
+ background-color: var(--color-info, #3b82f6);
+}
+
+.trial-banner {
+ display: grid;
+ align-items: center;
+ text-align: center;
+ font-size: var(--font-size-sm);
+ padding: var(--space-2) var(--space-4);
+ background-color: var(--color-info, #3b82f6);
+ color: var(--color-white);
+}
+
+.trial-banner a {
+ color: var(--color-white);
+ text-decoration: underline;
+}
+
+.trial-banner-expired {
+ background-color: var(--color-error, #dc2626);
+}
+
/*
section.grid-nav {
grid-column: 1/-1;
diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2
index 6c381ab..eb9c21e 100644
--- a/make_post_sell/templates/base.j2
+++ b/make_post_sell/templates/base.j2
@@ -65,7 +65,7 @@
{% if request.is_saas_domain == false and request.shop and request.shop.favicon %}
-
+
{% endif %}
{% if request.path == '/' and request.shop and request.shop.google_site_verification %}
@@ -87,6 +87,20 @@
{% include 'snippets/ribbon.j2' %}
+{% if request.shop and request.shop.is_non_production %}
+
+ {{ request.shop.environment_label|upper }} ENVIRONMENT — This shop is not visible to the public.
+
+{% endif %}
+{% if request.shop and request.shop.is_trial_active %}
+
+ Trial: {{ request.shop.trial_days_remaining }} day{{ 's' if request.shop.trial_days_remaining != 1 else '' }} remaining —
Choose a plan
+
+{% elif request.shop and request.shop.is_trial_expired %}
+
+{% endif %}
@@ -102,7 +116,7 @@
{% endif %}
{% elif request.shop and request.shop.logo_banner %}
-
+
{% else%}
Upload a logo in shop settings.
{% endif %}
diff --git a/make_post_sell/templates/cart.j2 b/make_post_sell/templates/cart.j2
index 810bc4f..fba3c78 100644
--- a/make_post_sell/templates/cart.j2
+++ b/make_post_sell/templates/cart.j2
@@ -121,7 +121,7 @@
{% if "thumbnail1" in product.extensions %}
-
+
{% endif %}
diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2
index c993fbc..79e570a 100644
--- a/make_post_sell/templates/content.j2
+++ b/make_post_sell/templates/content.j2
@@ -10,11 +10,11 @@
{%- if "thumbnail1" in product.extensions %}
-
+
-
+
{%- endif %}
{# Removed auto-refresh timer - better UX to let download links expire than interrupt reading #}
@@ -39,7 +39,7 @@
{% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %}
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
- {% set watch_video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
+ {% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}