Update from Copier template v0.4.0

- Accept RBAC system: user_roles table, role_required decorator, grant_role/revoke_role/ensure_admin_role functions
- Accept improved billing architecture: billing_customers table separation, provider-agnostic naming
- Accept enhanced user loading with subscription/roles eager loading in app.py
- Accept improved email templates with branded styling
- Accept new infrastructure: migration tracking, transaction logging, A/B testing
- Accept template improvements: Resend SDK, Tailwind build stage, UMAMI analytics config
- Keep beanflows-specific configs: BASE_URL 5001, coffee PLAN_FEATURES/PLAN_LIMITS
- Keep beanflows analytics integration and DuckDB health check
- Add new test files and utility scripts from template

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 22:22:13 +01:00
parent 1e8a173ae8
commit 4b7d4d5a74
20 changed files with 1346 additions and 387 deletions

View File

@@ -9,10 +9,13 @@ from hypothesis import strategies as st
from beanflows.billing.routes import (
can_access_feature,
get_billing_customer,
get_subscription,
get_subscription_by_provider_id,
is_within_limits,
record_transaction,
update_subscription_status,
upsert_billing_customer,
upsert_subscription,
)
from beanflows.core import config
@@ -45,7 +48,6 @@ class TestUpsertSubscription:
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_abc",
provider_subscription_id="sub_xyz",
current_period_end="2025-06-01T00:00:00Z",
)
@@ -53,39 +55,53 @@ class TestUpsertSubscription:
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["status"] == "active"
assert row["paddle_customer_id"] == "cust_abc"
assert row["paddle_subscription_id"] == "sub_xyz"
assert row["provider_subscription_id"] == "sub_xyz"
assert row["current_period_end"] == "2025-06-01T00:00:00Z"
async def test_update_existing_subscription(self, db, test_user, create_subscription):
original_id = await create_subscription(
test_user["id"], plan="starter", status="active",
paddle_subscription_id="sub_old",
async def test_update_existing_by_provider_subscription_id(self, db, test_user):
"""upsert finds existing by provider_subscription_id, not user_id."""
await upsert_subscription(
user_id=test_user["id"],
plan="starter",
status="active",
provider_subscription_id="sub_same",
)
returned_id = await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_new",
provider_subscription_id="sub_new",
provider_subscription_id="sub_same",
)
assert returned_id == original_id
row = await get_subscription(test_user["id"])
assert row["plan"] == "pro"
assert row["provider_subscription_id"] == "sub_same"
assert row["paddle_subscription_id"] == "sub_new"
async def test_different_provider_id_creates_new(self, db, test_user):
"""Different provider_subscription_id creates a new row (multi-sub support)."""
await upsert_subscription(
user_id=test_user["id"],
plan="starter",
status="active",
provider_subscription_id="sub_first",
)
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_subscription_id="sub_second",
)
from beanflows.core import fetch_all
rows = await fetch_all(
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at",
(test_user["id"],),
)
assert len(rows) == 2
async def test_upsert_with_none_period_end(self, db, test_user):
await upsert_subscription(
user_id=test_user["id"],
plan="pro",
status="active",
provider_customer_id="cust_1",
provider_subscription_id="sub_1",
current_period_end=None,
)
@@ -93,6 +109,28 @@ class TestUpsertSubscription:
assert row["current_period_end"] is None
# ════════════════════════════════════════════════════════════
# upsert_billing_customer / get_billing_customer
# ════════════════════════════════════════════════════════════
class TestUpsertBillingCustomer:
async def test_creates_billing_customer(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_abc")
row = await get_billing_customer(test_user["id"])
assert row is not None
assert row["provider_customer_id"] == "cust_abc"
async def test_updates_existing_customer(self, db, test_user):
await upsert_billing_customer(test_user["id"], "cust_old")
await upsert_billing_customer(test_user["id"], "cust_new")
row = await get_billing_customer(test_user["id"])
assert row["provider_customer_id"] == "cust_new"
async def test_get_returns_none_for_unknown_user(self, db):
row = await get_billing_customer(99999)
assert row is None
# ════════════════════════════════════════════════════════════
# get_subscription_by_provider_id
# ════════════════════════════════════════════════════════════
@@ -102,10 +140,8 @@ class TestGetSubscriptionByProviderId:
result = await get_subscription_by_provider_id("nonexistent")
assert result is None
async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_findme")
async def test_finds_by_provider_subscription_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], provider_subscription_id="sub_findme")
result = await get_subscription_by_provider_id("sub_findme")
assert result is not None
assert result["user_id"] == test_user["id"]
@@ -117,18 +153,14 @@ class TestGetSubscriptionByProviderId:
class TestUpdateSubscriptionStatus:
async def test_updates_status(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd")
await create_subscription(test_user["id"], status="active", provider_subscription_id="sub_upd")
await update_subscription_status("sub_upd", status="cancelled")
row = await get_subscription(test_user["id"])
assert row["status"] == "cancelled"
assert row["updated_at"] is not None
async def test_updates_extra_fields(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_extra")
await create_subscription(test_user["id"], provider_subscription_id="sub_extra")
await update_subscription_status(
"sub_extra",
status="active",
@@ -141,9 +173,7 @@ class TestUpdateSubscriptionStatus:
assert row["current_period_end"] == "2026-01-01T00:00:00Z"
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active")
await create_subscription(test_user["id"], provider_subscription_id="sub_known", status="active")
await update_subscription_status("sub_unknown", status="expired")
row = await get_subscription(test_user["id"])
assert row["status"] == "active" # unchanged
@@ -155,22 +185,22 @@ class TestUpdateSubscriptionStatus:
class TestCanAccessFeature:
async def test_no_subscription_gets_free_features(self, db, test_user):
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_active_pro_gets_all_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "api") is True
assert await can_access_feature(test_user["id"], "priority_support") is True
async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
assert await can_access_feature(test_user["id"], "export") is True
assert await can_access_feature(test_user["id"], "all_commodities") is False
assert await can_access_feature(test_user["id"], "api") is False
async def test_cancelled_still_has_features(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="cancelled")
@@ -183,7 +213,7 @@ class TestCanAccessFeature:
async def test_expired_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await can_access_feature(test_user["id"], "api") is False
assert await can_access_feature(test_user["id"], "dashboard") is True
assert await can_access_feature(test_user["id"], "basic") is True
async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="past_due")
@@ -203,30 +233,28 @@ class TestCanAccessFeature:
# ════════════════════════════════════════════════════════════
class TestIsWithinLimits:
async def test_free_user_no_api_calls(self, db, test_user):
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
async def test_free_user_within_limits(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 50) is True
async def test_free_user_commodity_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "commodities", 0) is True
assert await is_within_limits(test_user["id"], "commodities", 1) is False
async def test_free_user_at_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_free_user_history_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "history_years", 4) is True
assert await is_within_limits(test_user["id"], "history_years", 5) is False
async def test_free_user_over_limit(self, db, test_user):
assert await is_within_limits(test_user["id"], "items", 150) is False
async def test_pro_unlimited(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="active")
assert await is_within_limits(test_user["id"], "commodities", 999999) is True
assert await is_within_limits(test_user["id"], "items", 999999) is True
assert await is_within_limits(test_user["id"], "api_calls", 999999) is True
async def test_starter_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="starter", status="active")
assert await is_within_limits(test_user["id"], "api_calls", 9999) is True
assert await is_within_limits(test_user["id"], "api_calls", 10000) is False
assert await is_within_limits(test_user["id"], "items", 999) is True
assert await is_within_limits(test_user["id"], "items", 1000) is False
async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription):
await create_subscription(test_user["id"], plan="pro", status="expired")
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
assert await is_within_limits(test_user["id"], "items", 100) is False
async def test_unknown_resource_returns_false(self, db, test_user):
assert await is_within_limits(test_user["id"], "unicorns", 0) is False
@@ -238,7 +266,7 @@ class TestIsWithinLimits:
# ════════════════════════════════════════════════════════════
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
FEATURES = ["dashboard", "export", "api", "priority_support"]
FEATURES = ["basic", "export", "api", "priority_support"]
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
@@ -282,9 +310,9 @@ async def test_plan_feature_matrix(db, test_user, create_subscription, plan, fea
@pytest.mark.parametrize("plan", PLANS)
@pytest.mark.parametrize("resource,at_limit", [
("commodities", 1),
("commodities", 65),
("api_calls", 0),
("items", 100),
("items", 1000),
("api_calls", 1000),
("api_calls", 10000),
])
async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit):
@@ -307,11 +335,11 @@ async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resou
# ════════════════════════════════════════════════════════════
class TestLimitsHypothesis:
@given(count=st.integers(min_value=0, max_value=100))
@given(count=st.integers(min_value=0, max_value=10000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
async def test_free_limit_boundary_commodities(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "commodities", count)
assert result == (count < 1)
async def test_free_limit_boundary_items(self, db, test_user, count):
result = await is_within_limits(test_user["id"], "items", count)
assert result == (count < 100)
@given(count=st.integers(min_value=0, max_value=100000))
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
@@ -319,7 +347,56 @@ class TestLimitsHypothesis:
# Use upsert to avoid duplicate inserts across Hypothesis examples
await upsert_subscription(
user_id=test_user["id"], plan="pro", status="active",
provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp",
provider_subscription_id="sub_hyp",
)
result = await is_within_limits(test_user["id"], "commodities", count)
result = await is_within_limits(test_user["id"], "items", count)
assert result is True
# ════════════════════════════════════════════════════════════
# record_transaction
# ════════════════════════════════════════════════════════════
class TestRecordTransaction:
async def test_inserts_transaction(self, db, test_user):
txn_id = await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_abc123",
type="payment",
amount_cents=2999,
currency="EUR",
status="completed",
)
assert txn_id is not None and txn_id > 0
from beanflows.core import fetch_one
row = await fetch_one(
"SELECT * FROM transactions WHERE provider_transaction_id = ?",
("txn_abc123",),
)
assert row is not None
assert row["user_id"] == test_user["id"]
assert row["amount_cents"] == 2999
assert row["currency"] == "EUR"
assert row["status"] == "completed"
async def test_idempotent_on_duplicate_provider_id(self, db, test_user):
await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_dup",
amount_cents=1000,
)
# Second insert with same provider_transaction_id should be ignored
await record_transaction(
user_id=test_user["id"],
provider_transaction_id="txn_dup",
amount_cents=9999,
)
from beanflows.core import fetch_all
rows = await fetch_all(
"SELECT * FROM transactions WHERE provider_transaction_id = ?",
("txn_dup",),
)
assert len(rows) == 1
assert rows[0]["amount_cents"] == 1000 # original value preserved