← Multi-tenant roadmap

Phase 3 beta verification — 2026-08-25

Verified against merge commit c4318ca7 (PRs #300#302, SP-614/SP-615/SP-616), beta environment

All checks executed 2026-08-25 ICT (2026-08-24 19:05–19:30 UTC)

Verdict

All three Phase 3 tickets verified on beta

Forty-one automated checks passed (12 for SP-614, 12 for SP-615, 14 for SP-616, plus 3 deployment-currency checks). Three minor findings were filed (SP-626, SP-627, SP-628). Two items were not exercisable on beta and are listed under Not verified on beta. Three observations were recorded. None of the findings block the phase.

41Checks passed
3Findings filed
2Not verified
3Observations

What was verified

One row per Phase 3 ticket. Each result links to the evidence section below.

TicketScopeResultEvidence
SP-614 Per-tenant email identity and optional custom SMTP (PR #300) PASS Section 4.1
SP-615 Per-tenant legal identity and generic legal pages (PR #301) PASS Section 4.2
SP-616 Tenant-aware payments via payments.providers (PR #302) PASS Section 4.3

Deployment currency

Before any behavioural check, the beta environment was confirmed to be running the head of main. Three deployment-currency checks passed.

Environment state

Beta runs merge commit c4318ca7

main HEADc4318ca7 — “feat(tenant): tenant-aware payments via payments.providers entitlement (SP-616) (#302)”. The Phase 3 stack on main is 3f375eff (#300, SP-614) → 45fb61db (#301, SP-615) → c4318ca7 (#302, SP-616).
BackendGitHub Actions “Deploy Beta” run 32760215113 succeeded, created 2026-08-24T18:03:36Z for c4318ca7. Cloud Run service lms-backend-beta (asia-southeast1), latest ready revision lms-backend-beta-00253-gd6, image tag c4318ca7602247f68d4efd083698b023614821db.
FrontendDigitalOcean app lms-beta-frontend, deployment 47666549-4bc7-4985-b072-e7557794b24f, state ACTIVE 5/5, cause “commit c4318ca pushed”, 2026-08-24T18:06:26Z. Served index.html last-modified 2026-08-24 18:05:47 GMT. Main bundle /assets/index-DiOEO0nx.js contains the Phase 3 markers payment_providers, legalEntityName, has_password, privacy_url and the “Payments are not available” copy.
DatabaseBeta Alembic head a1f7c2d94b83 (payments.providers backfill) equals the newest migration in the repository. Chain 689e5242694b6d0b6b079f9a (tenant email_settings + default-tenant address backfill) → a3f7c1d9e58b (tenant legal) → a1f7c2d94b83. Columns tenants.email_settings and tenants.legal exist.
OpsAPP_ENCRYPTION_KEY is set in Infisical beta (45-character Fernet key), proven by the 200 on the custom-SMTP save (the API answers 503 without it). EMAIL_DELIVERY_ENABLED is not set on beta, so every send stays a no-op (see Not verified on beta).
CheckExpectedObservedResult
1. main HEAD and Phase 3 stack Head of main is the SP-616 merge; the stacked chain is present. c4318ca7 on HEAD. Stack 3f375eff45fb61dbc4318ca7. PASS
2. Backend deployment Cloud Run beta serves that merge commit. Deploy Beta run 32760215113 success. Ready revision lms-backend-beta-00253-gd6, image tag c4318ca7602247f68d4efd083698b023614821db. PASS
3. Frontend deployment DigitalOcean beta frontend serves the same commit, with Phase 3 markers in the bundle. Deployment ACTIVE 5/5, cause “commit c4318ca pushed”. Bundle contains payment_providers, legalEntityName, has_password, privacy_url and the “Payments are not available” copy. PASS

Evidence by ticket

Each card records the checks executed, the observed responses, and the supporting screenshots.

4.1 — PR #300

SP-614 — Per-tenant email identity and optional custom SMTP

SP-614 PR #300 PASS 12 CHECKS

All checks against HistoryClass (tenant 7c1e9f42-3b6a-4d58-9e10-2f4c8a7b5d31, host historyclass.beta.skillpixel.vn) with the beta admin account (skillpixeldeveloper@gmail.com, TENANT_ADMIN on HistoryClass) unless stated. Layer 1 default is the tenant name on the platform address.

CheckExpectedObservedResult
1. Baseline GET /admin/tenant-settings/email 200: from_name null, reply_to null, effective_from_name “HistoryClass”, effective_from_email info@skillpixel.vn, effective_reply_to null, custom SMTP disabled, has_password false. As expected. Layer 1 default = tenant name on the platform address. PASS
2. PUT identity 200 for from_name “HistoryClass Team”, reply_to hello@historyclass.vn. Effective from-name “HistoryClass Team”, from-email remains info@skillpixel.vn, effective Reply-To hello@historyclass.vn. As expected. Platform address retained, as documented. PASS
3. GET again Values persisted. Values persisted. PASS
4. PUT custom SMTP 200 for enabled SMTP: host smtp.historyclass.example, port 587, username mailer@historyclass.example, password supplied, from_email noreply@historyclass.example, STARTTLS on. has_password true. Password never echoed. 200. has_password true. Password has 0 occurrences in the response. PASS
5. Database audit of the stored password Fernet ciphertext in tenants.email_settings.custom_smtp.password_encrypted. Plaintext absent from the row. Value starts with “gAAAAAB”, length 120. The plaintext does not appear anywhere in the row. PASS
6. Partial PUT without a password field 200. Port 465, STARTTLS off. has_password stays true. 200. has_password stayed true. Port and STARTTLS updated. The partial-PUT wipe fix holds. PASS
7. Validation Custom SMTP enabled without a host → 422 “Custom SMTP requires a host and a from address”. reply_to “not-an-email” → 422. Both requests returned 422 with the documented messages. PASS
8. Authorisation PUT by a signed-in non-member (curoa99@gmail.com) → 403 “Tenant membership is required”. Anonymous GET → 403. As expected. PASS
9. POST /admin/tenant-settings/email/test 200 {"sent": false, "detail": "Email delivery is disabled on this environment"} on the shared relay and with custom SMTP enabled. As expected. The environment guard answers cleanly instead of attempting a send. PASS
10. Default tenant isolation Default tenant (beta.skillpixel.vn) GET: from_name null, effective_from_name “SkillPixel”, effective_from_email info@skillpixel.vn, has_password false. Unchanged after all HistoryClass writes. PASS
11. Admin UI /admin/email Saved identity and SMTP block, masked password with the keep-password hint, the from preview line, the test-email panel, and a sidebar “Email” entry. See screenshot 01. PASS
12. Logs Sends are no-ops on beta. The notification path runs for a non-default tenant without error. Two “Email delivery skipped outside production” entries at 19:15:21 UTC, from the enrollment notifications of the free checkout in SP-616. PASS
HistoryClass tenant admin Email page after saving sender identity and custom SMTP.
01 — HistoryClass tenant admin, /admin/email after saving. Sender identity “HistoryClass Team” / Reply-To hello@historyclass.vn. Preview line “Email được gửi từ: HistoryClass Team <noreply@historyclass.example>”. Custom SMTP block enabled with host smtp.historyclass.example, port 465, masked password and hint “Để trống để giữ mật khẩu đã lưu”, STARTTLS off, and the “Gửi email thử” panel. Sidebar shows the new “Email” entry.
4.2 — PR #301

SP-615 — Per-tenant legal identity and generic legal pages

SP-615 PR #301 PASS 12 CHECKS
CheckExpectedObservedResult
1. Migration backfill Default tenant legal entity and contact filled. HistoryClass legal empty before the test. Default canonical_url is https://beta.skillpixel.vn (no www). Default legal.entity_name = “Công Ty Cổ Phần Doanh Nghiệp Xã Hội SkillPixel”, links.phone “+84-934-099-409”, links.address “Số 15A Trần Khánh Dư, Phường Tân Tiến, Thành phố Nha Trang, Tỉnh Khánh Hòa, Việt Nam”. HistoryClass legal {} before the test. Canonical URL as expected. PASS
2. PUT /admin/tenant-settings on HistoryClass 200 for legal {entity_name "Công Ty TNHH HistoryClass", tax_code "0101234567"} and links {contact_email hello@historyclass.vn, phone "+84-900-123-456", address "12 Nguyễn Trãi, Hà Nội"}. 200. PASS
3. GET /tenant/bootstrap Legal and links reflected. No SkillPixel copy in the payload. Legal and links reflected. The only “skillpixel” substring is inside the beta hostname (canonical_url / logo path), not copy. PASS
4. privacy_url scheme validation A non-https, non-relative value is rejected with 422 “must be a relative path (/x) or an https URL”. 422 with that message for the javascript: probe value recorded on the ticket. PASS
5. External and relative legal URLs 200 for privacy_url https://historyclass.example/privacy and terms_url /terms. Bootstrap reflects both. 200. Bootstrap reflects both. PASS
6. HistoryClass /privacy-policy, signed out Title “Chính sách Bảo mật | HistoryClass”. Body contains “Công Ty TNHH HistoryClass” and hotline “+84-900-123-456”. Zero case-insensitive “SkillPixel” matches. Old hotline 0905121773 absent. As expected. Screenshot 02. PASS
7. HistoryClass /terms, signed out Title “Điều khoản Sử dụng | HistoryClass”. Entity name and hotline present. Any “skillpixel” match is only the interpolated beta site URL. The single “skillpixel” match is “https://historyclass.beta.skillpixel.vn” (the beta hostname — expected; a production tenant domain would not contain it). Screenshot 03. PASS
8. HistoryClass footer Quick links only “Điều khoản sử dụng” → /terms and “Chính sách bảo mật” → https://historyclass.example/privacy (target _blank). No About or FAQ. Contact block shows tenant phone, email and address. No “Về chúng tôi” (/about) and no “Câu hỏi thường gặp” (/faq). Contact “+84-900-123-456 / hello@historyclass.vn / 12 Nguyễn Trãi, Hà Nội”. Copyright “© 2025 HistoryClass.” (SP-623 known). Screenshot 04. PASS
9. HistoryClass /register consent links “Điều khoản sử dụng” → /terms (internal). “Chính sách Bảo mật” → https://historyclass.example/privacy (external, new tab). Signup consent follows the external URL. Screenshot 05. PASS
10. Tenant admin Branding page New “Pháp lý” card with entity name, tax code and both external-URL fields populated. Tên pháp nhân “Công Ty TNHH HistoryClass”, Mã số thuế 0101234567, Link Chính sách bảo mật riêng https://historyclass.example/privacy, Link Điều khoản sử dụng riêng /terms. Contact block shows hello@historyclass.vn / +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội. Screenshot 06. PASS
11. Default tenant legal pages and footer Title “Chính sách Bảo mật | SkillPixel”. Full registered entity and new hotline. Old hotline 0905121773 absent. Footer keeps About, FAQ, terms and privacy. Body shows “Công Ty Cổ Phần Doanh Nghiệp Xã Hội SkillPixel” and hotline “+84-934-099-409”. Footer keeps “Về chúng tôi” /about, “Câu hỏi thường gặp” /faq, /terms, /privacy-policy. Screenshots 11 and 12. PASS
12. Direct URL /faq and /about on HistoryClass Generic FAQ with no SkillPixel literal. About should not render SkillPixel copy on a non-default tenant. /faq renders the generic FAQ page with no SkillPixel literal in the body. /about renders SkillPixel's about page. Finding SP-626. Screenshot 13. PASS
HistoryClass privacy policy page, signed out, titled Chính sách Bảo mật | HistoryClass.
02 — HistoryClass /privacy-policy, signed out. Full page. Title “Chính sách Bảo mật | HistoryClass”.
HistoryClass terms page, signed out, titled Điều khoản Sử dụng | HistoryClass.
03 — HistoryClass /terms, signed out. Full page. Title “Điều khoản Sử dụng | HistoryClass”.
HistoryClass footer with tenant contact details and only terms and privacy quick links.
04 — HistoryClass footer. Contact block “+84-900-123-456 / hello@historyclass.vn / 12 Nguyễn Trãi, Hà Nội”. Quick links only “Điều khoản sử dụng” and “Chính sách bảo mật” (no About/FAQ). “© 2025 HistoryClass.” (SP-623 known).
HistoryClass register page showing consent links to terms and an external privacy URL.
05 — HistoryClass /register. Consent links “Điều khoản sử dụng” → /terms and “Chính sách Bảo mật” → https://historyclass.example/privacy (target _blank).
HistoryClass admin branding page with the new Pháp lý card populated.
06 — HistoryClass /admin/branding. Full page with the new “Pháp lý” card: Tên pháp nhân “Công Ty TNHH HistoryClass”, Mã số thuế 0101234567, Link Chính sách bảo mật riêng https://historyclass.example/privacy, Link Điều khoản sử dụng riêng /terms. Contact block shows hello@historyclass.vn / +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội.
Default tenant privacy policy page titled Chính sách Bảo mật | SkillPixel.
11 — Default tenant beta.skillpixel.vn/privacy-policy. Full page. Title “Chính sách Bảo mật | SkillPixel”.
Default tenant footer with About, FAQ, Terms and Privacy links.
12 — Default tenant footer. About/FAQ/Terms/Privacy links and the SkillPixel contact block.
HistoryClass about page opened by direct URL showing SkillPixel about copy under the HistoryClass header.
13 — HistoryClass /about opened by direct URL. Renders SkillPixel's about hero “Ươm mầm cho Tài năng Việt qua Giáo dục AI…” under the HistoryClass header. Finding SP-626.
4.3 — PR #302

SP-616 — Tenant-aware payments via payments.providers

SP-616 PR #302 PASS 14 CHECKS

Buyer is the beta admin account (a STUDENT member of HistoryClass). To exercise the paid path, the HistoryClass course “Lịch sử THPT - Ôn thi tốt nghiệp” (slug lich-su-thpt-on-thi-tot-nghiep) was temporarily priced at 150.000 ₫ (all HistoryClass courses are free on beta) and reverted afterwards.

CheckExpectedObservedResult
1. Migration backfill Exactly one payments.providers row, TENANT scope, default tenant, value ["ALEPAY","SEPAY"]. HistoryClass has no row. One row for tenant 0a46592b-9318-4e72-ae93-9635d7364d82 (skillpixel), updated_by sp-616-backfill. HistoryClass has no row. PASS
2. Bootstrap and providers, both tenants Default: bootstrap payment_providers ["ALEPAY","SEPAY"]; GET /payments/providers returns two providers. HistoryClass: bootstrap []; providers []; GET /platform/config/effective effective_value null (new tenants start off). As expected. PASS
3. Catalog entry GET /platform/config/catalog exposes payments.providers: value_type string_list, default [], editable_by platform_admin, is_feature false. As expected. PASS
4. GLOBAL-scope write rejected PUT /platform/config/entry at GLOBAL scope → 400 “Feature entitlements are per-tenant. Write this key at TENANT scope and supply a tenant_id.” 400 with that message. PASS
5. Unknown provider rejected PUT with value ["PAYPAL"] → 400 “Config key payments.providers requires a list of payment providers (ALEPAY, NGANLUONG, SEPAY).”. Effective value unchanged afterwards. 400. Effective value unchanged. PASS
6. Empty list: initiate POST /payments/initiate (course lich-su-thpt-on-thi-tot-nghiep, provider ALEPAY) → 403 “Payments are not available for this site”. 403 with that message. PASS
7. Empty list: paid cart checkout POST /cart/checkout with the paid course, provider SEPAY → 403 “Payments are not available for this site”. 403 with that message. PASS
8. Empty list: free cart Cart with the free course lich-su-viet-nam-the-ky-19-dau-20, POST /cart/checkout → 200. Zero-cost carts never reach the gate. Enrollment created. 200 {payment_required false, transaction_id 5JKUQJ64CBIIOIDUHJRH6, redirect to /payment/receipt}. Enrollment was created. PASS
9. TENANT list ["SEPAY"] PUT /platform/config/entry TENANT HistoryClass ["SEPAY"] → 200. Effective, bootstrap and providers all ["SEPAY"]. 200 (updated_by skillpixeldeveloper@gmail.com). Effective ["SEPAY"], matched_scope TENANT. Bootstrap ["SEPAY"]. GET /payments/providers["SEPAY"] only. PASS
10. Initiate with ["SEPAY"] ALEPAY initiate → 403 (provider outside the list). SEPAY initiate → 200 with payment required. Status PROCESSING then cancel → CANCELLED. ALEPAY → 403. SEPAY → 200 transaction JZEFR3NV7ORMP4T0CU6R2, payment_required true, redirect https://pay-sandbox.sepay.vn/v1/checkout/init. GET status → PROCESSING, SEPAY, 150000. POST cancel → CANCELLED. PASS
11. Cart checkout with ["SEPAY"] POST /cart/checkout SEPAY → 200. Cancel → CANCELLED. 200 transaction QQQ354A45297VS3W6WAOX. Cancelled → CANCELLED. PASS
12. Callbacks are not gated on the HistoryClass host Bogus SePay and AlePay webhook bodies → 400 “Invalid webhook payload” (not a 404 feature gate). SePay return → 422 (request validation, not a gate). NganLuong bogus body is a known 500. POST /payments/webhook/sepay and /webhook/alepay → 400 “Invalid webhook payload”. GET /payments/return/sepay → 422. POST /webhook/nganluong → 500 (pre-existing, finding SP-627). PASS
13. Default tenant isolation Default tenant GET /payments/providers still ["ALEPAY","SEPAY"] after all HistoryClass writes. Unchanged. PASS
14. Cart and platform-admin UI Empty list: payments-unavailable notice, no checkout button. ["SEPAY"]: SePay card hidden by the PostHog kill switch; maintenance notice shows. ["ALEPAY"]: checkout button active. Platform-admin Features tab shows the tenant override. Screenshots 07, 08, 09 and 10. Finding SP-628 on the maintenance copy. PASS
HistoryClass cart with a 150.000 dong course while payment providers are empty, showing an amber notice and no checkout button.
07 — HistoryClass /cart with payments.providers empty. A 150.000 ₫ course. Amber notice “Thanh toán hiện chưa khả dụng trên trang này.” and no checkout button.
HistoryClass cart with SePay as the only provider, showing the pre-existing maintenance notice.
08 — Same cart with payments.providers = ["SEPAY"]. The SePay PostHog kill switch is off on beta, so the SePay card is hidden and the pre-existing maintenance notice “Cổng thanh toán đang bảo trì … liên hệ SkillPixel qua Facebook” shows. Finding SP-628.
HistoryClass cart with AlePay as the only provider and an active checkout button.
09 — Same cart with payments.providers = ["ALEPAY"]. The “Đăng ký ngay” checkout button is active (AlePay is the only route).
Platform admin Features tab for HistoryClass showing the payment providers tenant override.
10 — Platform-admin (lms-admin staging) → Tenants → HistoryClass → Features tab. Full page. Bottom card “Other settings” shows “Payment providers — Tenant override” with value ["SEPAY"] and the catalog description “Providers a tenant may start checkouts with (ALEPAY, SEPAY, NGANLUONG). Empty disables new checkouts; provider callbacks keep working.”

Not verified on beta

Two items could not be exercised on this environment. They are covered by unit tests. They are not counted as failures.

Real email delivery

NOT EXERCISED SP-614

Real email delivery, rendered tenant-branded templates and the “[<tenant name>] …” reminder subjects were not exercised. EMAIL_DELIVERY_ENABLED is unset on beta, so every send is a no-op (“Email delivery skipped outside production”).

Covered by unit tests (test_email_identity.py, test_email_service.py, test_email_reminder_service.py). To exercise on beta: set EMAIL_DELIVERY_ENABLED=true in Infisical beta and roll a new Cloud Run revision, then press “Gửi email thử” on /admin/email. Note this also turns on real invitation and reminder mail on beta.

End-to-end provider webhook

NOT EXERCISED SP-616

A provider webhook for an enabled tenant's transaction completing end to end was not exercised. It needs a real SePay sandbox payment.

Covered by test_tenant_payment_gating.py. On beta only the ungated routing of the callback routes was confirmed (SP-616 check 12).

Database and logs audit

Reported separately from the 41 checks. The purpose is to confirm isolation and that the only errors since the deploy were the deliberate NganLuong probe.

Isolation and health

Default-tenant rows unchanged; two ERROR lines from the NganLuong probe

  • Alembic head a1f7c2d94b83. Columns tenants.email_settings and tenants.legal are present.
  • Cloud Run ERROR entries since the deploy (2026-08-24T18:00Z): 2, both at 19:09:46 UTC and both produced by the deliberate NganLuong webhook probe (SP-627). No other errors.
  • Default tenant rows for email settings, legal and payments.providers were unchanged by the test.
  • HistoryClass transactions after the test: 139 COMPLETED 0 ₫ (free checkout), 140 and 141 CANCELLED 150.000 ₫ (SePay initiations cancelled by the test).

Findings

Three bugs were filed. None block the phase. Three observations follow.

SP-626 — /about and /faq still served on non-default tenants by direct URL

MINOR SP-615 SP-626

/about and /faq are still served on non-default tenants by direct URL. /about renders SkillPixel's about page (title “Về SkillPixel | HistoryClass”). SP-615 only hid the footer links.

Expected: not-found or redirect on non-default tenants. Visible in screenshot 13.

Jira: SP-626

SP-627 — NganLuong webhook returns 500 on a malformed body

LOW PRE-EXISTING SP-616 SP-627

Pre-existing since 2026-01-19. Surfaced by SP-616 check 12. POST /payments/webhook/nganluong with a malformed body returns 500 (unhandled pydantic ValidationError at payment.py line 374) while the AlePay and SePay webhooks return 400. Not tenant-related.

Jira: SP-627

SP-628 — SePay maintenance notice names SkillPixel on a tenant cart

MINOR SP-616 SP-628

The SePay kill-switch maintenance notice on the cart says “liên hệ SkillPixel qua Facebook” on a HistoryClass page (frontend key payment.maintenance). Visible in screenshot 08.

Jira: SP-628

O1 — custom_smtp: null means “no change”

OBSERVATION

PUT /admin/tenant-settings/email with custom_smtp: null leaves the stored custom SMTP untouched (null means “no change”). To disable, the client sends the object with enabled false — the admin UI does this. Documented behaviour, no action.

O2 — Shared-relay From address stays the platform address

OBSERVATIONKNOWN LIMIT

On the shared relay the From address stays info@skillpixel.vn for every tenant (known limit recorded on the ticket and the roadmap). Only the from-name and Reply-To are per tenant. The admin page states this in its help text.

O3 — Footer copyright year remains “© 2025”

OBSERVATIONSP-623

SP-623 (footer “© 2025”) is still visible on every footer capture. Unchanged, already ticketed.

Test residue and cleanup

All residue on beta is intentional and recorded here so that a later reader does not mistake it for real data.

State left on beta

Staging identity kept; payments off; course price reverted

  • HistoryClass email settings. from_name “HistoryClass Team” and reply_to hello@historyclass.vn KEPT (sensible staging identity). Custom SMTP disabled and cleared (host/username/from_email null, password_encrypted null, has_password false).
  • HistoryClass legal. entity_name “Công Ty TNHH HistoryClass” and tax_code 0101234567 KEPT (fictional staging identity, matching the runbook example). privacy_url and terms_url cleared (null). Contact links hello@historyclass.vn / +84-900-123-456 / 12 Nguyễn Trãi, Hà Nội KEPT.
  • payments.providers. HistoryClass now has a TENANT row with value [] (payments off, explicit), description “HistoryClass is not selling on beta (reset after Phase 3 verification 2026-08-25)”.
  • Course price and cart. Course lich-su-thpt-on-thi-tot-nghiep cost reverted to 0. The admin's cart was emptied.
  • Left in place. Transactions 139/140/141 (see audit) and the enrollment of skillpixeldeveloper@gmail.com in lich-su-viet-nam-the-ky-19-dau-20 created by the free checkout.
  • Bootstrap cache. The public site picks up tenant-setting changes within about five minutes.

Method

Checks were API-driven: curl against the beta Cloud Run API with Firebase ID tokens minted for the beta admin account. The database audit was performed with psql on the beta Supabase database. The log audit was performed with gcloud. Screenshots come from clean headless Chromium (Playwright) with the Firebase session injected.

Verified by Claude (Fable 5) on 2026-08-25 ICT (2026-08-24 19:05–19:30 UTC).