Jun 28 - Implement payment functions (Stripe)
This commit is contained in:
@@ -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
@@ -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 #}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user