Jun 28 - Implement payment functions (Stripe)

This commit is contained in:
2026-06-28 12:01:25 -04:00
parent 61ede27093
commit f292c8fb7b
21 changed files with 1112 additions and 18 deletions
@@ -0,0 +1,105 @@
"""control0002 — Stripe billing columns (MT-8)
Adds billing state columns to the control-plane `tenants` table and
`stripe_price_id` to the `plans` table.
All column additions use INFORMATION_SCHEMA existence checks so the
migration is safe to re-run (CLAUDE.md Rule 14). No ENUM change to the
existing `tenants.status` column — `subscription_status` is a separate
nullable column tracking billing lifecycle independently of operational state.
"""
import sqlalchemy as sa
from alembic import op
revision = 'control0002_billing'
down_revision = 'control0001_init'
branch_labels = None
depends_on = None
def _column_exists(bind, table: str, column: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c"
), {'t': table, 'c': column})
return result.scalar() > 0
def _index_exists(bind, table: str, index_name: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i"
), {'t': table, 'i': index_name})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
# ── plans: stripe_price_id ─────────────────────────────────────────────
if not _column_exists(bind, 'plans', 'stripe_price_id'):
op.execute(sa.text(
"ALTER TABLE plans ADD COLUMN stripe_price_id VARCHAR(100) NULL"
))
# ── tenants: stripe_customer_id ────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'stripe_customer_id'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN stripe_customer_id VARCHAR(64) NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_customer'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_stripe_customer (stripe_customer_id)"
))
# ── tenants: stripe_subscription_id ───────────────────────────────────
if not _column_exists(bind, 'tenants', 'stripe_subscription_id'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN stripe_subscription_id VARCHAR(64) NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_stripe_sub'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_stripe_sub (stripe_subscription_id)"
))
# ── tenants: subscription_status ──────────────────────────────────────
if not _column_exists(bind, 'tenants', 'subscription_status'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN subscription_status "
"ENUM('trial','active','past_due','cancelled') NULL"
))
if not _index_exists(bind, 'tenants', 'ix_tenants_sub_status'):
op.execute(sa.text(
"ALTER TABLE tenants ADD INDEX ix_tenants_sub_status (subscription_status)"
))
# ── tenants: trial_ends_at ─────────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'trial_ends_at'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN trial_ends_at DATETIME NULL"
))
# ── tenants: current_period_end ────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'current_period_end'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN current_period_end DATETIME NULL"
))
# ── tenants: billing_email ─────────────────────────────────────────────
if not _column_exists(bind, 'tenants', 'billing_email'):
op.execute(sa.text(
"ALTER TABLE tenants ADD COLUMN billing_email VARCHAR(255) NULL"
))
def downgrade():
bind = op.get_bind()
for col in ('billing_email', 'current_period_end', 'trial_ends_at',
'subscription_status', 'stripe_subscription_id', 'stripe_customer_id'):
if _column_exists(bind, 'tenants', col):
op.execute(sa.text(f"ALTER TABLE tenants DROP COLUMN {col}"))
if _column_exists(bind, 'plans', 'stripe_price_id'):
op.execute(sa.text("ALTER TABLE plans DROP COLUMN stripe_price_id"))
+13 -1
View File
@@ -41,9 +41,10 @@ class Plan(ControlBase):
allow_scheduled_reports = Column(Boolean, nullable=False, default=False)
allow_branding = Column(Boolean, nullable=False, default=False)
# Billing (future — MT-8)
# Billing (MT-8)
price_cents = Column(Integer, nullable=True)
billing_period = Column(String(16), nullable=True)
stripe_price_id = Column(String(100), nullable=True)
created_at = Column(DateTime, nullable=False, default=now_eastern)
@@ -100,6 +101,17 @@ class Tenant(ControlBase):
suspended_at = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
# ── Billing (MT-8) ─────────────────────────────────────────────────────
stripe_customer_id = Column(String(64), nullable=True, index=True)
stripe_subscription_id = Column(String(64), nullable=True, index=True)
subscription_status = Column(
Enum('trial', 'active', 'past_due', 'cancelled', name='subscription_status'),
nullable=True,
)
trial_ends_at = Column(DateTime, nullable=True)
current_period_end = Column(DateTime, nullable=True)
billing_email = Column(String(255), nullable=True)
plan = relationship('Plan', back_populates='tenants')
domains = relationship('TenantDomain', back_populates='tenant',
cascade='all, delete-orphan')
@@ -268,7 +268,7 @@
</div>
{# ── Quick info ── #}
<div class="card border-0 shadow-sm">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-link-45deg me-1"></i>Primary URL</span>
</div>
@@ -286,6 +286,70 @@
</div>
</div>
{# ── Billing (MT-8, read-only) ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
</div>
<div class="card-body py-2" style="font-size:.83rem;">
<div class="mb-1">
<span class="text-muted">Subscription</span>
<div>
{% if tenant.subscription_status == 'active' %}
<span class="badge bg-success">active</span>
{% elif tenant.subscription_status == 'trial' %}
<span class="badge bg-info text-dark">trial</span>
{% elif tenant.subscription_status == 'past_due' %}
<span class="badge bg-warning text-dark">past_due</span>
{% elif tenant.subscription_status == 'cancelled' %}
<span class="badge bg-danger">cancelled</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</div>
{% if tenant.trial_ends_at %}
<div class="mb-1">
<span class="text-muted">Trial ends</span>
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div>
</div>
{% endif %}
{% if tenant.current_period_end %}
<div class="mb-1">
<span class="text-muted">Period ends</span>
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div>
</div>
{% endif %}
{% if tenant.billing_email %}
<div class="mb-1">
<span class="text-muted">Billing email</span>
<div>{{ tenant.billing_email }}</div>
</div>
{% endif %}
{% if tenant.stripe_customer_id %}
<div class="mb-1">
<span class="text-muted">Stripe customer</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_customer_id }}">
{{ tenant.stripe_customer_id[:20] }}…
</div>
</div>
{% endif %}
{% if tenant.stripe_subscription_id %}
<div class="mb-1">
<span class="text-muted">Stripe subscription</span>
<div class="font-monospace" style="font-size:.75rem;"
title="{{ tenant.stripe_subscription_id }}">
{{ tenant.stripe_subscription_id[:20] }}…
</div>
</div>
{% endif %}
{% if not tenant.subscription_status %}
<span class="text-muted">No billing configured.</span>
{% endif %}
</div>
</div>
</div>{# /col-lg-4 #}
</div>{# /row #}
+7
View File
@@ -119,6 +119,13 @@ def tenant_detail(tenant_id):
'db_host': t.db_host, 'db_port': t.db_port,
'alembic_head': t.alembic_head, 'created_at': t.created_at,
'suspended_at': t.suspended_at, 'notes': t.notes,
# MT-8: billing fields (read-only in panel)
'subscription_status': t.subscription_status,
'trial_ends_at': t.trial_ends_at,
'current_period_end': t.current_period_end,
'stripe_customer_id': t.stripe_customer_id,
'stripe_subscription_id': t.stripe_subscription_id,
'billing_email': t.billing_email,
}
plan_rows = [{'id': p.id, 'code': p.code, 'name': p.name} for p in plans]
domain_rows = [