Skip to content

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.


In one sentence:

System-Kisum-Checkout is 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 — so Backend-Kisum-Auth stays focused on identity and Frontend-Kisum-Website stays 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/* and Backend-Kisum-Core /internal/* APIs. New endpoints added for this service: POST /internal/admin/users/{id}/sessions (mints tokens for a freshly created user) and GET /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_enabledMarketing site posts toWhat Checkout does
truePOST /api/checkout/signupProvisions status='trial' rows for Basic + every cart addon. No Xendit call. Hands the user tokens directly.
falsePOST /api/checkout/sessionOpens 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.

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:

  1. JWKS-verifies the shared kisum_access_token cookie (.kisum.io) via Auth’s /.well-known/jwks.json.
  2. Confirms the user has an active membership on companyId via Auth’s new GET /internal/admin/users/{id}/companies.
  3. Opens a Xendit Payment Session anchored to today + 1 month for the recurring config.
  4. On session-complete, creates a Xendit Customer + Recurring Plan matching the company’s getCompanyEntitlements() set and flips Basic + addons from trial to active in Core.
  5. 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>
RoutePurpose
POST /api/billing/upgrade/sessionAuth + validate add-on; $0 add-ons → Core POST /internal/companies/{id}/addons immediately (mode: free); paid → Xendit Payment Session (mode: paid)
POST /api/billing/upgrade/finalizeAfter Xendit session-complete: provision add-on in Core + rebuild Recurring Plan to match entitlements
POST /api/billing/upgrade/cancelCancel active add-on (status='cancelled' in Core); CORS-enabled for product-app origins (PRODUCT_LOGIN_URL*)

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).

This page records cross-service positioning. The repository is the living reference for routes, payload shapes, and env.


Marketing /create (step 1: account + company form)
↓ POST /api/checkout/session
Checkout (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.


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)

MethodPathAuthPurpose
POST/api/checkout/sessionnone (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]noneRe-hydrate draft on /create refresh / 3DS return.
POST/api/checkout/finalizenone (idempotent by referenceId)Step 2. Verifies session, extracts payment_token_id, runs the full provisioning pipeline, returns tokens.
POST/api/checkout/signupnone (CORS-restricted)No-card free-trial path (VITE_TRIAL_CARD=false on marketing). Provisions Basic + every catalog add-on as trial.
GET/api/geo/countriesnone (CORS-restricted)Country dropdown on marketing /create. Proxies Backend-Kisum-Artists via ARTISTS_INTERNAL_*.
GET/api/geo/countries/:code/citiesnone (CORS-restricted)City lookup for signup form (same Artists upstream).
POST/api/webhooks/xendit/recurringx-callback-tokenRecurring lifecycle. cycle.succeeded → Core upsert active; terminated/inactive/stopped → Core upsert cancelled.
POST/api/webhooks/xendit/paymentsx-callback-tokenIdempotent ack-only safety net for session events. Provisioning still runs via the browser-initiated finalize today.

Checkout is intentionally stateless except for Redis drafts. It does not own any platform master data.

DataOwnerWhere
Signup draft between session + finalizeCheckoutRedis key checkout:pending:{referenceId} (TTL XENDIT_CHECKOUT_SESSION_TTL_MIN, default 30 min)
Xendit Customer / Recurring PlanXendit (external)Referenced by id; Checkout stores ids in Core company metadata.xendit
Users + sessions + memberships + permission grantsAuthAuth DB — see Data ownership
Companies + commercial state + subscriptions + add-onsCoreCore DB
Pricing tableCheckoutsrc/lib/pricing.ts (server-side; marketing site mirrors for display only)

  • Secrets stay server-side: XENDIT_SECRET_KEY, XENDIT_WEBHOOK_TOKEN, AUTH_INTERNAL_API_KEY, CORE_INTERNAL_API_KEY live in System-Kisum-Checkout/.env.local only. The browser never sees them. No NEXT_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) and src/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_ORIGINSPOST /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 .env only — 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 deprecated MARKET_INTERNAL_* names.
  • Webhook token: verified with crypto.timingSafeEqual against XENDIT_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.ts table are charged.

Terminal window
# /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/api
cd Backend-Kisum-Core && go run ./cmd/api
# 2. Checkout
cd System-Kisum-Checkout
cp .env.example .env.local # fill XENDIT_*, AUTH_INTERNAL_API_KEY, CORE_INTERNAL_API_KEY
npm install
npm 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 site
cd Frontend-Kisum-Website
# .env.local: VITE_CHECKOUT_BASE_URL=http://checkout.local.kisum.io:3098
npm 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.

  • 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.

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.