S
supero.docs
Documentation/Case Studies/Case Study: A Subscription Business, End to End

Case Study: A Subscription Business, End to End

IronWorks Three — 39 schemas, sixteen platform services, and a workflow that fires the moment a class is booked. Including the billing loop the platform does not run for you.

A gym application: plans, subscriptions, classes, bookings, payments and a workflow fired on booking
Fifteen object types of the builder’s own, twenty-four imported by the services they lean on.

The Brief

A gym. Members pay monthly for a plan, the plan entitles them to book classes, classes are led by trainers and have a capacity, everyone signs a waiver before setting foot on the floor, and the shop by the door sells protein and towels. Payments go through Stripe; confirmations go out by email.
Count the systems a team would normally assemble for that: a subscription biller, a booking calendar, an e-signature flow, a small store, a payments integration, transactional email. Six problems, none of them what the owner cares about.
The exemplar is app_m71-gym3 in ~/supero-test/e2e-tests/apps/app_m71-gym3 — "IronWorks Three", namespace ironworks_three, domain m71-corp. A real generated bundle with 39 schemas; everything named here is read out of it.

The Model

Of the 39 schemas, fifteen belong to the application — carrying "schema_source": "app" — and twenty-four arrived with the services it imports, marked "schema_source": "service". That ratio is the story of the build: most of the model was not written.
python
{"name": "member_subscription",
 "namespace": "ironworks_three",
 "schema_type": "object",
 "parent_type": "tenant",
 "extends": "membership:base_membership",
 "attributes": [
     {"name": "billing_interval", "type": "string", "default": "month"},
     {"name": "auto_renew", "type": "boolean", "default": True},
     {"name": "subscribed_at", "type": "string"},
     {"name": "owner_username", "type": "string", "role": "owner"}],
 "references": [
     {"name": "MembershipPlan", "cardinality": "one",
      "back_ref_name": "subscriptions", "role": "primary_item"},
     {"name": "MemberProfile", "cardinality": "one",
      "back_ref_name": "subscriptions", "role": "actor"}]}
Four fields and two references. Everything that makes it a subscription — the status lifecycle, joined_at, suspended_at, expires_at, suspension_reason — comes from the base type.
App typeExtendsIts own contribution
membership_planrecurring_plan:base_planplan_name, category (Basic, Premium, VIP), benefits, sort_order
member_subscriptionmembership:base_membershipbilling_interval, auto_renew, subscribed_at
member_profilecustomer:base_customerphone, emergency_contact_name, fitness_goals, has_signed_waiver
classservice:base_serviceclass_name, category, max_capacity, day_of_week, schedule_start_time
class_bookingbooking:base_bookingtrainer_uuid, booked_at, start_time, end_time
paymentpayment:base_paymentpaid_at, plus mandatory amount and currency
cart, cart_item, order, order_itemcart:*, order:*The shop by the door
waiverdocument_signature:base_documentwaiver_text, version, title
waiver_signaturedocument_signature:base_signaturesigned_at, parented to the waiver it signs
trainer(no base type)trainer_name, bio, photo, specialties, experience_years
trainer is the reminder that not everything should extend something. A base type it does not need buys a state machine nobody will drive.
The sixteen services in config.py: customer, product, cart, payment, stripe_checkout, membership, booking, notification, attachment, document_signature, email, amazon_s3, service, recurring_plan, order, workflows. Importing them pulled in the twenty-four extra schemas — email alone adds campaign, message, sender, suppression and template.

Building It in App Studio

  1. 1

    Describe the business

    App Studio takes the brief in plain language. The gym’s config.py still carries it: "Premium gym memberships, classes, and personal training."

  2. 2

    The services get picked

    The generator records the implied services in config.py. That list decides which base types are available to extend.

  3. 3

    The model comes back

    Fifteen app schemas, each already extending the right base type. class extends service:base_service, class_booking extends booking:base_booking — a calendar, from a sentence about classes.

  4. 4

    Configure the integrations

    They ship imported but uncredentialed, listed in INTEGRATIONS_NEEDING_CONFIG. Until filled in, calls fail with a clear error rather than silently doing nothing.

  5. 5

    Put the keys in the keystore

    Via the admin panel or the bundle’s configure_service.py. Credentials live at /api/v1/keystore/platform-secrets, masked on read, never in the repository.

  6. 6

    Run it

    The bundle is a working application: schemas.py, ui_schemas.py, ui/app.js, setup.py, run.sh and docker-compose.on-prem.yml.

python
INTEGRATIONS_NEEDING_CONFIG = [
  {"service_id": "stripe_checkout",
   "config_keys": ["stripe_api_key", "stripe_webhook_secret"]},
  {"service_id": "email",
   "config_keys": ["sendgrid_api_key", "sendgrid_webhook_signing_key",
                   "default_from_email", "default_from_name"]},
  {"service_id": "amazon_s3",
   "config_keys": ["aws_access_key_id", "aws_secret_access_key",
                   "default_bucket"]},
]
Once configured, every service action goes through one endpoint — POST /api/v1/services/execute, with a service_id, an operation, and an input naming the extending_schema. There is no separate cart or membership API. An illegal transition returns 409 INVALID_STATE_TRANSITION.

Tenancy and Access

The hierarchy is domain, project, tenant, record. A record’s address is its immutable fq_name — here ["m71-corp", "m71-gym3", <tenant>, <record>]. The identifier is uuid; there is no id. A gym chain puts each location in its own tenant from one deployment; app_m71-gym3 seeds a single default-tenant because it is a test bundle, but the isolation is there either way. See Projects & Tenants.
Access comes from a fixed role set. The eight you assign are domain_admin, domain_user, project_admin, project_user, developer, tenant_admin, tenant_user and viewer; platform_admin and platform_user are Supero-operator roles you will not hand out. There is no super_admin. The gym seeds three: developer, tenant_admin for the manager, tenant_user for a member.

⚠️ Two things people get wrong here

There is no X-Domain, X-Tenant or X-Project header — tenancy comes from the authenticated principal and the CRUD path /api/v1/crud/{domain}/{type}, never from something a client sets. And the auth headers are mutually exclusive: Authorization: Bearer <jwt> or X-API-Key: ak_..., not both.
One detail worth copying: member_profile extends customer:base_customer, parented to user_account rather than the tenant — a member is a person who logs in, not a row with an email field. And member_subscription carries owner_username with "role": "owner", which is how a member’s own subscription is told from everyone else’s.

The Booking Fires a Workflow

For the complete set, see Workflow Engine Reference.
A member books a 6am class. Nobody is at the desk, and the confirmation still has to go out. This is what event bindings are for: a CRUD event fires a workflow, no polling and no scheduled job.
json
// app_m71-gym/event_bindings.json
[{"event": "@create:ironworks:class_booking",
  "workflow_id": "notify_on_booking",
  "input_map": {"booking_uuid": "uuid",
                "class_name": "class_name"}}]
@create:ironworks:class_booking is the syntax @<verb>:<namespace>:<schema> — when a class_booking is created in ironworks, fire notify_on_booking. The input_map carries two fields into the workflow: the booking’s uuid, and the class_name the confirmation quotes back.

ℹ️ Which file this came from

app_m71-gym3 records its service list in config.py rather than a services.json, and ships no event_bindings.json. The binding above is quoted verbatim from its sibling app_m71-gym (namespace ironworks), whose services.json reads ["booking", "cart", "customer", "membership", "notification", "order", "payment", "recurring_plan"].
The workflow is a CRUD record of type wf:workflow_definition. Steps can be service_call, crud_operation, condition, parallel or transform, each with on_error of stop, continue, retry or compensate. Every run writes a workflow_instance and every step a workflow_step_log — queryable when a member says the email never arrived. The run stamps trigger_source: crud_event, not api or scheduled.

What It Took

What came free

  • •Twenty-four of the 39 schemas — imported service types with lifecycle fields and state machines behind them.
  • •Six problems from one description: subscriptions, bookings, e-signature, cart and orders, payments, email.
  • •Credential hygiene by default — Stripe and SendGrid keys in the keystore, masked on read.
  • •Automation on a real event, with a per-step log you can query afterwards.
  • •Tenancy and the platform roles, with fq_name as an immutable record address.

What had to be decided

  • •That trainer gets no base type while class extends service:base_service. Extending everything is a real temptation and a real cost.
  • •That waiver_signature is parented to waiver rather than merely referencing it — parenting is what makes the signature belong to the document.
  • •That the plan vocabulary is Basic, Premium, VIP, declared as field values so the tiers cannot drift.
  • •That has_signed_waiver is a denormalised flag — convenient at check-in, now something you must keep true.

What would bite you

🚨 Nothing charges the member next month

The gym has billing_interval, auto_renew, next_billing_at and current_period_end. Not one causes anything to happen. Supero runs no billing scheduler: it does not charge on a date, retry a failed card, dun, or send a renewal reminder. There are no free trials either — no trial concept exists on the base plan. The renewal loop is a workflow you define; until you do, subscriptions sit where you last set them. Budget for this at the start of a subscription build.
The second thing is quieter. class.max_capacity is a field to check against, not a lock the platform takes — two members can pass the same check on the same 6am class. start_time and end_time make availability queryable, but the contention rule is yours. Neither is hard; both are invisible until the first oversubscribed morning. For the complete set of roles see Roles & Permissions Reference, and for the full workflow step and error contract see Workflow Engine Reference.