Skip to content

REFORMATION BRIEF R-02 — THE TILL

Stripe Checkout + Entitlement + Finance Tabs

UCCA Platform · 18 March 2026


R-01 opened the door. R-02 puts the till on the counter. This brief migrates Stripe from ops-db to engine-db, wires the dual auth gate to checkout, and surfaces financial data at all four layers.


SURFACE: ucca-ops (ops.ucca.online) + rtopacks-site (rtopacks.com.au) + ucca-api (engine/ucca-engine/backend/) DO NOT TOUCH: ucca-site, ucca-ir, ucca-keys, ucca-track Cloudflare Account: e5a9830215a8d88961dc6c80a8c7442a engine-db: 0efa8970-0053-4623-8436-4e877af10887 rtopacks-db: 334ac8fb Stripe account: RTOPacks.com.au (live keys, AUD) Legal entity: United Central Colleges of Australia Pty Ltd · ABN 59 168 872 535


→ ALEX

1. Schema — engine-db

Add the purchases table:

CREATE TABLE IF NOT EXISTS purchases (
  id                        TEXT PRIMARY KEY,
  tenant_id                 TEXT REFERENCES tenants(id),     -- NULL for guest (should not happen post-R-02)
  user_id                   TEXT REFERENCES users(id),
  stripe_customer_id        TEXT NOT NULL,
  stripe_payment_intent_id  TEXT UNIQUE NOT NULL,
  stripe_invoice_id         TEXT,
  product_id                TEXT NOT NULL,
  price_id                  TEXT NOT NULL,
  amount_aud                INTEGER NOT NULL,                 -- in cents
  gst_aud                   INTEGER NOT NULL DEFAULT 0,      -- in cents
  status                    TEXT NOT NULL CHECK (status IN ('pending','paid','refunded','disputed')),
  entitlement_json          TEXT NOT NULL DEFAULT '{}',      -- what they get, stub for now
  delivery_status           TEXT NOT NULL DEFAULT 'pending',
  created_at                INTEGER NOT NULL DEFAULT (unixepoch()),
  paid_at                   INTEGER,
  refunded_at               INTEGER
);

CREATE INDEX IF NOT EXISTS idx_purchases_tenant    ON purchases(tenant_id);
CREATE INDEX IF NOT EXISTS idx_purchases_user      ON purchases(user_id);
CREATE INDEX IF NOT EXISTS idx_purchases_stripe_pi ON purchases(stripe_payment_intent_id);
CREATE INDEX IF NOT EXISTS idx_purchases_status    ON purchases(status);

Also add Stripe customer ID to users table:

ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT UNIQUE;

2. Dual Auth Gate — Pre-Checkout

You cannot buy without verifying email + mobile first.

Add a gate page to rtopacks.com.au at /checkout-auth.

Flow: 1. User clicks "Buy" on any course pack in the catalogue 2. If they have a valid verified session cookie (30-day) → skip to Stripe checkout directly 3. If no verified session → redirect to /checkout-auth?redirect=/checkout?pack_id=XXXXX

The /checkout-auth page: - Same dual auth flow as /claim (Auth0 email OTP + Twilio Verify SMS) - On success: set a 30-day verified session cookie (ucca_verified_session) - Redirect to the original checkout URL

Verification gate copy — use exactly this:

Heading: Your content is built to your specification. Subheading: We verify who you are so it stays yours. Body: The training context, learner descriptors, and organisational details you provide are your IP. We take that seriously.

Then the email + mobile fields. No security language. No alarm bells.


3. Stripe Checkout — rtopacks-site

Add checkout route to rtopacks.com.au at /api/checkout.

// POST /api/checkout
// Body: { pack_id, price_id, user_email, tenant_id (optional) }

// 1. Get or create Stripe customer
const customer = await stripe.customers.list({ email: user_email, limit: 1 });
let stripeCustomer = customer.data[0];
if (!stripeCustomer) {
  stripeCustomer = await stripe.customers.create({
    email: user_email,
    metadata: { tenant_id: tenant_id || 'guest' }
  });
  // Update user record in engine-db with stripe_customer_id
}

// 2. Create Stripe Checkout session
const session = await stripe.checkout.sessions.create({
  customer: stripeCustomer.id,
  payment_method_types: ['card'],
  line_items: [{
    price: price_id,
    quantity: 1,
  }],
  mode: 'payment',
  // GST handled by Stripe Tax — enable on RTOPacks.com.au Stripe account
  automatic_tax: { enabled: true },
  customer_update: { address: 'auto' },
  success_url: 'https://rtopacks.com.au/dashboard?purchase=success',
  cancel_url: 'https://rtopacks.com.au/checkout?cancelled=true',
  metadata: {
    tenant_id: tenant_id || '',
    pack_id: pack_id,
  }
});

return { url: session.url };

Redirect user to session.url (Stripe-hosted checkout page).

Invoice footer text (set in Stripe dashboard → Settings → Customer emails): United Central Colleges of Australia Pty Ltd · ABN 59 168 872 535 · Trading as RTOpacks


4. Stripe Webhook — Migrate to engine-db

Current webhook at ops.ucca.online/api/stripe/webhook writes to ops-db. Migrate writes to engine-db. Keep the endpoint URL — do not change the Stripe webhook config.

On payment_intent.succeeded:

# 1. Extract metadata from payment intent
tenant_id = payment_intent.metadata.get('tenant_id') or None
pack_id = payment_intent.metadata.get('pack_id')

# 2. Get user by stripe_customer_id
user = db.get_user_by_stripe_customer_id(payment_intent.customer)

# 3. Write purchase to engine-db
purchase = {
    "id": uuid4(),
    "tenant_id": tenant_id,
    "user_id": user.id if user else None,
    "stripe_customer_id": payment_intent.customer,
    "stripe_payment_intent_id": payment_intent.id,
    "product_id": pack_id,
    "price_id": payment_intent.metadata.get('price_id', ''),
    "amount_aud": payment_intent.amount,
    "gst_aud": payment_intent.metadata.get('tax_amount', 0),
    "status": "paid",
    "entitlement_json": json.dumps({"pack_id": pack_id, "delivery": "pending"}),
    "delivery_status": "pending",
    "paid_at": int(time.time())
}
db.insert("purchases", purchase)

# 4. Write platform audit log
write_audit(ctx, "purchase.completed", "purchases", purchase["id"], purchase, request)

# 5. If no user account exists for this email — create L4 account
if not user:
    provision_l4_user(email=payment_intent.receipt_email, stripe_customer_id=payment_intent.customer)

On charge.refunded: - Update purchase status to refunded, set refunded_at - Write audit log: purchase.refunded

On payment_intent.payment_failed: - Write purchase record with status pending - Write audit log: purchase.failed

Deprecate ops-db Stripe writes — comment out, do not delete.


5. L4 Auto-Provisioning on Purchase

If a buyer has no existing account (anonymous checkout), create one:

def provision_l4_user(email: str, stripe_customer_id: str, tenant_id: str = None):
    # Create user in engine-db
    user = {
        "id": uuid4(),
        "email": email,
        "stripe_customer_id": stripe_customer_id,
        "display_name": email.split('@')[0]
    }
    db.insert("users", user)

    # If tenant_id known, grant L4 role
    if tenant_id:
        db.insert("user_tenant_roles", {
            "id": uuid4(),
            "user_id": user["id"],
            "tenant_id": tenant_id,
            "role": "L4",
            "granted_by": "00000000-0000-0000-0000-000000000001"
        })

    # Create Auth0 user
    auth0_management_api.create_user(email=email)

    # Send welcome email + SMS via Auth0 passwordless
    # "Your purchase is confirmed. Log in at rtopacks.com.au to access your content."
    send_confirmation(email=email, stripe_customer_id=stripe_customer_id)

6. Finance Tabs — All Four Layers

Add a Finance tab to the ops console and dashboard surfaces. All data pulled live from Stripe API — nothing stored locally except what's in the purchases table.

L1 — /w/finance in ops console

Platform-level view. Pull from Stripe Connect org level.

Cards: - Total gross volume (AUD) — all time - MRR (monthly recurring — $0 until subscriptions added) - Total transactions — count from purchases table - By tenant — table: tenant slug, purchase count, total AUD

Stripe API calls:

// Balance
stripe.balance.retrieve()
// Recent charges
stripe.charges.list({ limit: 100 })
// Pull tenant breakdown from engine-db purchases table

L2 — /w/finance in ops console (same page, scoped view)

Same as L1 but read-only. No tenant drill-down. Just totals. L2 users see: Total processed, transaction count, balance available for payout.

L3 — Finance tab on /dashboard

RTO admin view. Their purchases only.

// Pull from engine-db — filter by tenant_id
// Pull Stripe Customer Portal session for self-service
const portalSession = await stripe.billingPortal.sessions.create({
  customer: tenant.stripe_customer_id,
  return_url: 'https://rtopacks.com.au/dashboard',
});

Cards: - Purchase history table: pack name, date, amount, status, download link (stub) - "Manage billing" button → opens Stripe Customer Portal - Total spent (AUD)

L4 — Finance tab on /dashboard (learner/buyer view)

Buyer view. Their purchases only, scoped to their user_id.

Cards: - What you've bought: pack name, date, amount, access link (stub) - "Download receipt" → Stripe Customer Portal link - "Need help?" → support email link


7. Stripe Tax — Enable on RTOPacks.com.au Account

In Stripe dashboard → RTOPacks.com.au account → More → Tax: - Enable Stripe Tax - Set origin address: 149 Wickham Tce, Spring Hill QLD 4000 - Product tax code: txcd_10000000 (general digital services) - This auto-calculates and remits GST for Australian buyers

No code change needed — automatic_tax: { enabled: true } in checkout session handles it.


In Stripe dashboard → RTOPacks.com.au → Settings → Customer emails → Invoice footer:

United Central Colleges of Australia Pty Ltd · ABN 59 168 872 535 · Trading as RTOpacks
149 Wickham Tce, Spring Hill QLD 4000

This appears on every receipt/invoice automatically.


9. New API Endpoints

Endpoint Method Auth Purpose
/api/checkout POST Verified session Create Stripe checkout session
/api/checkout-auth GET/POST None Dual auth gate pre-checkout
/api/finance/summary GET L1/L2 JWT Platform finance summary
/api/finance/tenant GET L3 JWT Tenant purchase history
/api/finance/user GET L4 JWT User purchase history
/api/portal POST L3/L4 JWT Create Stripe Customer Portal session

OPS STUB TASK

Add to ops console under new FINANCE world at Level 1:

R-02 · THE TILL
purchases table in engine-db ✓
Stripe webhook migrated to engine-db ✓
Dual auth gate live pre-checkout ✓
L4 auto-provisioning on purchase ✓
Stripe Tax enabled ✓
Invoice footer set ✓
Finance tab live — L1/L2/L3/L4 ✓
ops-db Stripe writes deprecated ✓

→ TIM

What this brief does:

R-02 puts the till on the counter. Four things:

  1. You cannot buy anonymously. Before checkout, every buyer verifies email + mobile — same dual auth as the RTO claim flow. The gate copy is warm, not scary: "Your content is built to your specification. We verify who you are so it stays yours." Verified session lasts 30 days — verify once, buy as many times as you want.

  2. Stripe checkout is wired to the platform. When payment succeeds, a purchase record is created in engine-db scoped to the buyer's tenant and user ID. If the buyer has no account, one is created automatically. They get a confirmation email and SMS. They can always log in and find their purchase — no more "I lost my PDF" support calls.

  3. GST is handled. Stripe Tax enabled on the RTOPacks.com.au account — auto-calculates 10% GST for Australian buyers. Every receipt shows the correct legal entity: United Central Colleges of Australia Pty Ltd ABN 59 168 872 535 trading as RTOpacks.

  4. Finance is visible at every layer. L1 (you) sees platform totals. L2 (staff) sees gateway health. L3 (RTO admin) sees their purchases and can self-serve billing via Stripe Customer Portal. L4 (learner) sees what they bought and can download their receipt. Stripe holds the money and the records — we just surface them.

What this is NOT: - Not delivery — entitlement records are created but delivery mechanism is stubbed (R-03) - Not subscriptions — one-off purchases only for now - Not the full scope explorer — R-03 - Not beautiful — functional finance tabs, no design polish yet

The test: An RTO finds a course pack, verifies their identity, pays, gets a confirmation SMS, logs into their dashboard, and sees their purchase. That's the first dollar through the gateway.


REFORMATION BRIEF R-02 · UCCA Inc · 18 March 2026 "You can browse anonymously. You cannot buy anonymously."