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:
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.
8. Stripe Dashboard — Invoice Footer¶
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:
-
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.
-
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.
-
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.
-
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."