System-Kisum-Checkout (self-serve signup + Xendit payments)
Related documentation: Backend modules overview · Backend Auth · Backend Core · Data ownership · Kisum System.
Canonical implementation detail: repository System-Kisum-Checkout — start with README.md, then MEMORY.md for decisions, then AGENTS.md for boundaries.
Local dev host: checkout.local.kisum.io:3098. Production host: checkout.kisum.io.
Purpose
Section titled “Purpose”In one sentence:
System-Kisum-Checkoutis the payments BFF that owns the entire Kisum self-serve signup flow — Xendit Payment Sessions, the embedded Components card iframe, Recurring Plan creation, post-signup provisioning, webhook handling, AND post-trial card capture for existing customers via/billing/add-card— soBackend-Kisum-Authstays focused on identity andFrontend-Kisum-Websitestays a pure marketing site.
Platform role:
- Public face for
POST /api/checkout/session(paid signup),GET /create,POST /api/checkout/finalize,POST /api/checkout/signup(no-card trial),GET /billing/add-card+POST /api/billing/{session,finalize}(existing-customer card capture), and the Xendit webhook receivers. - Server-side holder of
XENDIT_SECRET_KEY,XENDIT_WEBHOOK_TOKEN,AUTH_INTERNAL_API_KEY,CORE_INTERNAL_API_KEY. None of these ever reach the browser. - Caller of the existing
Backend-Kisum-Auth/internal/admin/*andBackend-Kisum-Core/internal/*APIs. New endpoints added for this service:POST /internal/admin/users/{id}/sessions(mints tokens for a freshly created user) andGET /internal/admin/users/{id}/companies(membership rows used to gate/billing/add-card).
Trial vs paid path (2026-05-20 Trial Flags + Persona Pricing)
Section titled “Trial vs paid path (2026-05-20 Trial Flags + Persona Pricing)”The trial decision is data-driven from Core, not a build flag:
packages.trial_enabled | Marketing site posts to | What Checkout does |
|---|---|---|
true | POST /api/checkout/signup | Provisions status='trial' rows for Basic + every cart addon. No Xendit call. Hands the user tokens directly. |
false | POST /api/checkout/session | Opens a Xendit Payment Session (mode=COMPONENTS, card_on_file_type=RECURRING + recurring_configuration). User completes the card iframe on /create; /api/checkout/finalize creates the Customer + Recurring Plan and provisions status='active' rows. |
Hard guard: /api/checkout/signup refuses non-trial packages; /api/checkout/session refuses trial packages. The marketing site decides which to call by fetching the chosen package from Core’s /public/packages?key=… and reading trialEnabled client-side.
Persona handoff
Section titled “Persona handoff”The signup bundle includes audience: 'promoter' | 'venue'. After provisioning, the post-signup productLoginUrl is resolved per-persona from PRODUCT_LOGIN_URL_PROMOTER (Frontend-Kisum) or PRODUCT_LOGIN_URL_VENUE (Frontend-Kisum-Venues).
Post-trial card capture (/billing/add-card)
Section titled “Post-trial card capture (/billing/add-card)”When a trial company wants to keep the subscription alive past ends_at, the product app links the user to:
https://checkout.kisum.io/billing/add-card?companyId=<uuid>&returnTo=<absolute-url>Checkout:
- JWKS-verifies the shared
kisum_access_tokencookie (.kisum.io) via Auth’s/.well-known/jwks.json. - Confirms the user has an active membership on
companyIdvia Auth’s newGET /internal/admin/users/{id}/companies. - Opens a Xendit Payment Session anchored to today + 1 month for the recurring config.
- On
session-complete, creates a Xendit Customer + Recurring Plan matching the company’sgetCompanyEntitlements()set and flips Basic + addons fromtrialtoactivein Core. - Redirects back to the product app’s
returnTo.
Product apps never embed Xendit Components themselves. See Frontend-Kisum/src/components/billing/BillingAddCardLink.tsx for the canonical entry-point component.
Existing-company add-on purchase (/billing/upgrade)
Section titled “Existing-company add-on purchase (/billing/upgrade)”Since 2026-06-09, product apps (starting with Frontend-Kisum-Promoters /profile/billing) can add add-ons to an existing company:
https://checkout.kisum.io/billing/upgrade?companyId=<uuid>&addonKey=<key>&returnTo=<absolute-url>| Route | Purpose |
|---|---|
POST /api/billing/upgrade/session | Auth + validate add-on; $0 add-ons → Core POST /internal/companies/{id}/addons immediately (mode: free) + owner grant reseed; paid → Xendit Payment Session (mode: paid) |
POST /api/billing/upgrade/finalize | After Xendit session-complete: provision add-on in Core + rebuild Recurring Plan to match entitlements + owner grant reseed |
POST /api/billing/upgrade/cancel | Cancel active add-on (status='cancelled' in Core) + owner grant reseed (strips the revoked module’s grants); CORS-enabled for product-app origins (PRODUCT_LOGIN_URL*) |
Owner grant reseed (2026-07-14): every post-signup add-on change (free session, paid finalize, cancel) now replaces the calling TENANT_SUPERADMIN membership’s Auth permission grants with the full catalog of Core enabledModules (same mechanism as signup step 7 and Admin approval reseeds). Core records the entitlement only — without the reseed, the new module’s routes 403 (e.g. middle_agency.workspace.view). Non-superadmin callers are never reseeded (a scoped member’s grants must not be escalated); responses carry permissionsSeeded: boolean.
Cancel is invoked cross-origin from the product app with credentials: 'include' (shared kisum_access_token on .kisum.io). Add uses a full navigation deep-link (no CORS).
Dev-only mock checkout (persona apps, 2026-06-26)
Section titled “Dev-only mock checkout (persona apps, 2026-06-26)”When Checkout is not configured or broken in local dev, Artists, Promoters, and Venues can enable an in-drawer mock payment wizard on /profile/billing instead of redirecting to /billing/upgrade.
- Frontend:
NEXT_PUBLIC_MOCK_BILLING_CHECKOUT=true - BFF:
POST /api/billing/mock-checkout/completewith{ addonKeys[] }(requiresMOCK_BILLING_CHECKOUT_ENABLED=trueon the persona backend) - Provisions real Core add-ons (
source: mock.checkout); skips Xendit and recurring-plan sync - Reseeds the caller’s Auth permission grants (2026-07-14, Artists BFF): after provisioning, the caller’s TENANT_SUPERADMIN membership grants are replaced with the catalog of Core
enabledModulesvia Auth/internal/admin/*(requiresAUTH_BASE_URL+AUTH_INTERNAL_API_KEYon the persona backend). Response carriespermissionsSeeded+ optionalpermissionsWarning; the FE shows a warning toast when seeding failed - Repair endpoint
POST /api/billing/mock-checkout/reseed-permissions(dev-only) — reseeds owner grants from current entitlements without any add-on change, for companies entitled before the reseed fix. Surfaced as a “Sync permissions” button on the Artists billing page when mock checkout is enabled - Test cards:
4242…4242success,4000…0002decline
See also Promoters integration — mock checkout.
This page records cross-service positioning. The repository is the living reference for routes, payload shapes, and env.
Mental model
Section titled “Mental model”Marketing /create (step 1: account + company form) ↓ POST /api/checkout/sessionCheckout (Next.js) ↓ Xendit POST /sessions (mode=COMPONENTS, RECURRING card-on-file) ↑ componentsSdkKey + paymentSessionId ↓ Redis: stash signup body under `checkout:pending:{referenceId}` (TTL ~30 min) ↑ { paymentSessionId, componentsSdkKey, referenceId, redirectUrl }
Browser redirects to https://checkout.kisum.io/create?referenceId=… ↓ mount `xendit-components-web` against componentsSdkKey ↓ user enters card in Xendit-hosted iframe → SDK fires `session-complete`
POST /api/checkout/finalize { referenceId } ↓ Xendit GET /sessions/:id (assert completed, extract payment_token_id) ↓ Xendit POST /customer + POST /recurring/plans (anchor_date=now+TRIAL_DAYS, immediate_payment=false) ↓ Auth POST /internal/admin/users ↓ Core POST /internal/companies + /basic + /addons (status=trial) ↓ Auth POST /internal/admin/companies/{id}/memberships (TENANT_SUPERADMIN) ↓ Auth PUT /internal/admin/company-memberships/{mid}/permission-grants ↓ Auth POST /internal/admin/users/{id}/sessions (mint tokens) ↑ { accessToken, refreshToken, productLoginUrl } ↓ browser redirects to PRODUCT_LOGIN_URL?email=…The marketing site only knows checkout.kisum.io. Auth and Core are reached only server-to-server from Checkout.
Sequence
Section titled “Sequence”sequenceDiagram
participant User
participant Web as marketing
participant CK as System_Kisum_Checkout
participant XD as Xendit
participant Auth as Backend_Kisum_Auth
participant Core as Backend_Kisum_Core
User->>Web: Step 1 account + company form
Web->>CK: POST /api/checkout/session
CK->>CK: Persist signup draft in Redis
CK->>XD: POST /sessions mode=COMPONENTS
XD-->>CK: paymentSessionId + componentsSdkKey
CK-->>Web: redirectUrl
Web-->>User: window.location = checkout.kisum.io/create?referenceId=...
User->>CK: GET /create?referenceId=...
CK->>CK: Load draft + GET session for fresh sdkKey
User->>CK: submit card via Xendit iframe
XD-->>CK: session-complete event
CK->>XD: GET /sessions/:id (verify + extract payment_token_id)
CK->>XD: POST /customer + POST /recurring/plans
CK->>Auth: POST /internal/admin/users
CK->>Core: POST /internal/companies + basic + addons
CK->>Auth: POST /internal/admin/companies/.../memberships
CK->>Auth: PUT /internal/admin/company-memberships/.../permission-grants
CK->>Auth: POST /internal/admin/users/.../sessions
Auth-->>CK: { accessToken, refreshToken }
CK-->>User: redirect to PRODUCT_LOGIN_URL?email=...
XD-->>CK: POST /api/webhooks/xendit/recurring (cycle.succeeded)
CK->>Core: upsert basic + addons (status=active)
| Normal + paid package | Real price | /api/checkout/session → Xendit | After payment | N/A |
| Normal + trial package | Real price + trial note | /api/checkout/signup | Immediate login | N/A |
| Test submission | $0 display only (VITE_TEST_SUBMISSION=true) | POST /api/checkout/signup-request | Pending — no login | Trial from approval date |
Hard guard: marketing VITE_TEST_SUBMISSION is UX only. Checkout also requires server env TEST_SUBMISSION_ENABLED=true (mirrors SELF_SERVE_SIGNUP_ENABLED). Direct API calls return 403 registration_disabled when the server flag is off.
Test submission (probation signup) — POST /api/checkout/signup-request
Section titled “Test submission (probation signup) — POST /api/checkout/signup-request”When both flags are on, the marketing site shows $0 + NO CREDIT CARD NEEDED and submits an application, not a payment.
Pipeline (Checkout → Auth + Core):
- Create Auth user
approvalStatus=PENDING,isActive=false(machine create; default remainsAPPROVEDfor legacy/signup). - Create Core company
status=draft, metadata{ signupMode: 'test_submission', audience, packageKey, addonKeys, signupUserId }. - Create inactive
TENANT_SUPERADMINmembership. Permission grants wait until approval because the draft has no entitlements — no subscriptions, no session tokens. - Send Auth pending-registration email.
- Return
{ status: 'pending', email, userId, companyId, companyName }.
Admin approval (Frontend-Kisum-Admin → Backend-Kisum-Admin):
POST /api/v1/admin/signup-requests/{userId}/approve— retry-safe reconciliation: Auth user + active membership; Core package/add-ons + active company; owner permissions from enabled modules. It can resume after a partial failure.POST /api/v1/admin/companies/{companyId}/approve— the equivalent company-detail action; an active test submission exposes Repair access and reruns the same reconciliation.POST /api/v1/admin/signup-requests/{userId}/reject— AuthREJECTED; Core companyarchivedwhen draft test submission.PATCH /api/v1/admin/users/{id}— alias to platform-user patch (fixes tenant user edit from Admin UI).
Endpoints
Section titled “Endpoints”| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/checkout/session | none (CORS-restricted to MARKETING_ORIGIN) | Step 1. Validates account body, opens Xendit Payment Session, stashes draft in Redis, returns SDK key + redirect URL. |
| GET | /api/checkout/session/[referenceId] | none | Re-hydrate draft on /create refresh / 3DS return. |
| POST | /api/checkout/finalize | none (idempotent by referenceId) | Step 2. Verifies session, extracts payment_token_id, runs the full provisioning pipeline, returns tokens. |
| POST | /api/checkout/signup | none (CORS-restricted) | No-card free-trial path (VITE_TRIAL_CARD=false on marketing). Provisions Basic + every catalog add-on as trial. |
| POST | /api/checkout/signup-request | none (CORS-restricted) | Test-submission path when TEST_SUBMISSION_ENABLED=true. Creates pending user + draft company; no tokens. |
| GET | /api/geo/countries | none (CORS-restricted) | Country dropdown on marketing /create. Proxies Backend-Kisum-Artists via ARTISTS_INTERNAL_*. |
| GET | /api/geo/countries/:code/cities | none (CORS-restricted) | City lookup for signup form (same Artists upstream). |
| POST | /api/webhooks/xendit/recurring | x-callback-token | Recurring lifecycle. cycle.succeeded → Core upsert active; terminated/inactive/stopped → Core upsert cancelled. |
| POST | /api/webhooks/xendit/payments | x-callback-token | Idempotent ack-only safety net for session events. Provisioning still runs via the browser-initiated finalize today. |
| GET | /internal/companies/{companyId}/delete-preview | CHECKOUT_INTERNAL_API_KEY | Company hard-delete preview: Redis provision-progress counts + Xendit recurring state for every plan recorded in Core metadata (recurring_plan_id + previous_plan_ids). Blocker active_xendit_subscription (not force-ok) when any is ACTIVE/PENDING/REQUIRES_ACTION. |
| POST | /internal/companies/{companyId}/cancel-billing | CHECKOUT_INTERNAL_API_KEY | Deactivate every recorded Xendit recurring plan (current + upgrade history); clear provision progress. Xendit 404/DATA_NOT_FOUND per plan → already gone; Core company 404 → alreadyCancelled: true (resume path); tombstone company → 409. Called by Admin orchestrator only. |
Data ownership
Section titled “Data ownership”Checkout is intentionally stateless except for Redis drafts. It does not own any platform master data.
| Data | Owner | Where |
|---|---|---|
| Signup draft between session + finalize | Checkout | Redis key checkout:pending:{referenceId} (TTL XENDIT_CHECKOUT_SESSION_TTL_MIN, default 30 min) |
| Xendit Customer / Recurring Plan | Xendit (external) | Referenced by id; Checkout stores ids in Core company metadata.xendit |
| Users + sessions + memberships + permission grants | Auth | Auth DB — see Data ownership |
| Companies + commercial state + subscriptions + add-ons | Core | Core DB |
| Pricing table | Checkout | src/lib/pricing.ts (server-side; marketing site mirrors for display only) |
Security boundary
Section titled “Security boundary”- Secrets stay server-side:
XENDIT_SECRET_KEY,XENDIT_WEBHOOK_TOKEN,AUTH_INTERNAL_API_KEY,CORE_INTERNAL_API_KEYlive inSystem-Kisum-Checkout/.env.localonly. The browser never sees them. NoNEXT_PUBLIC_*aliases. - HTTPS required: Xendit Components rejects plain-HTTP origins. Local dev tunnels port 3098 through ngrok and adds the tunnel host to
XENDIT_COMPONENTS_ORIGINS. - CORS: Cross-origin browser calls are allow-listed by origin CSV env vars (see below). Middleware:
src/middleware.ts+src/lib/cors.ts(MARKETING_ORIGIN) andsrc/lib/billing-cors.ts(PRODUCT_ORIGINS). Code default when unset:src/lib/cors-origins.ts.MARKETING_ORIGIN—/api/checkout/*,/api/geo/*(marketing signup + country dropdown).PRODUCT_ORIGINS—POST /api/billing/upgrade/cancel(module apps with credentials).- Production CSV:
https://www.kisum.io,https://admin.kisum.io,https://app.kisum.io,https://kisum.io,https://artists.kisum.io,https://venues.kisum.io. - Local dev: append
http://local.kisum.io:5173,http://app.local.kisum.io:3000, etc. in.envonly — not in Worker secrets.
- Geo reference:
GET /api/geo/*proxies Backend-Kisum-Artists (ARTISTS_INTERNAL_BASE_URL=https://api-v2-artists.kisum.dev,ARTISTS_INTERNAL_API_KEY). Do not use deprecatedMARKET_INTERNAL_*names. - Webhook token: verified with
crypto.timingSafeEqualagainstXENDIT_WEBHOOK_TOKEN. Length mismatch → false without leaking timing. - Pricing trust: the marketing site can POST any module names; only matches in the server-side
pricing.tstable are charged.
Local dev
Section titled “Local dev”# /etc/hosts (one-time)127.0.0.1 checkout.local.kisum.io
# 1. Start Auth (:3097), Core (:3096), Redis as usual.cd Backend-Kisum-Auth && go run ./cmd/apicd Backend-Kisum-Core && go run ./cmd/api
# 2. Checkoutcd System-Kisum-Checkoutcp .env.example .env.local # fill XENDIT_*, AUTH_INTERNAL_API_KEY, CORE_INTERNAL_API_KEYnpm installnpm run dev # 0.0.0.0:3098
# 3. HTTPS tunnel for Xendit Components (port 3098, not the marketing 5173)ngrok http http://checkout.local.kisum.io:3098 \ --host-header=checkout.local.kisum.io \ --domain=enjoyably-snipping-tackling.ngrok-free.dev
# 4. Marketing sitecd Frontend-Kisum-Website# .env.local: VITE_CHECKOUT_BASE_URL=http://checkout.local.kisum.io:3098npm run dev # 0.0.0.0:5173
# 5. Xendit dashboard — update webhook URLs:# POST https://<ngrok>/api/webhooks/xendit/recurring# POST https://<ngrok>/api/webhooks/xendit/payments# and add the ngrok hostname to the Domains allow-list.What lives elsewhere (do not duplicate)
Section titled “What lives elsewhere (do not duplicate)”- Identity / sessions / JWT / JWKS / password reset / login / refresh / logout —
Backend-Kisum-Auth. - Company master data, package + module catalog, subscription truth —
Backend-Kisum-Core. - Marketing landing pages, cart sidebar UX, pricing display copy —
Frontend-Kisum-Website. - Product app surface (post-login) —
Frontend-Kisum. - Webhook handling for any non-Xendit payment provider — none today; would go here if added.
Breaking change (2026-05-20)
Section titled “Breaking change (2026-05-20)”Backend-Kisum-Auth no longer exposes POST /auth/signup, POST /auth/checkout, POST /auth/checkout/session, or any /webhooks/xendit/* routes. Callers were the marketing site only; it now POSTs to System-Kisum-Checkout endpoints listed above. Auth gained one new internal endpoint — POST /internal/admin/users/{id}/sessions — used only by Checkout to mint tokens for a freshly-provisioned user.