Codebase-derived evidence
Every flow on this page is sourced directly from these files:
| Area | Source | Architectural meaning |
| Security chain | backend/src/main/java/com/cplk/api/config/SecurityConfig.java | JWT resource server · route allowlist · rate-limit & tenant filters in chain |
| Tenant isolation | tenant/TenantFilter.java · TenantFilterAspect.java · TenantAwareEntity.java · BypassTenantFilter.java | JWT claim → ThreadLocal → Hibernate filter on every repo call |
| Property workflow | config/PropertyStateMachineConfig.java · property/PropertyWorkflowService.java | State machine · role-gated transitions · history + audit + notify |
| Payment lifecycle | payment/PaymentController.java · PaymentService.java · PayHereRecurringService.java · PayHereHashGenerator.java | Webhook-driven idempotent status changes · preapproval token · recurring charges |
| Frontend client | frontend/src/lib/api.ts · frontend/src/services/*.ts | Axios with refresh queue · retries · SSR/CSR URL switch |
Sequence A · Authenticated request + tenant isolation
The most fundamental flow in CPLK. Every protected API call walks this path.
The thread-local TenantContext exists only between filter entry and
the filter's finally block — never longer.
sequenceDiagram
autonumber
participant B as Browser
participant SC as SecurityFilterChain
participant RL as RateLimitFilter
participant TF as TenantFilter
participant CTL as PropertyController
participant SVC as PropertyService
participant ASP as TenantFilterAspect
participant HIB as Hibernate
participant PG as PostgreSQL
B->>SC: GET /api/properties · Bearer JWT
SC->>SC: validate signature (JWKS cache)
SC->>SC: map realm_access.roles → ROLE_*
SC->>RL: forward
RL->>RL: consume token from per-endpoint bucket
RL->>TF: forward (or 429 if empty)
TF->>TF: agencyId = JwtUtils.extractAgencyId(jwt)
TF->>TF: TenantContext.set(agencyId)
TF->>CTL: @PreAuthorize check (hasRole / SecurityUtils)
CTL->>SVC: search(filters)
SVC->>ASP: propertyRepository.findAll(spec, page)
ASP->>HIB: session.enableFilter("tenantFilter").setParameter("agencyId", id)
HIB->>PG: SELECT … WHERE agency_id = ? AND deleted = false …
PG-->>HIB: rows
HIB-->>ASP: entities
ASP-->>SVC: page<Property>
SVC-->>CTL: PagedResponse<PropertyResponse> (MapStruct)
CTL-->>TF: ApiResponse.ok(page)
TF->>TF: finally → TenantContext.clear()
TF-->>B: 200 OK · JSON envelope
Even a SUPER_ADMIN goes through this filter. To cross tenants they must use a method explicitly marked @BypassTenantFilter — the aspect then skips enableFilter entirely.
Sequence B · Property publish & approval
The agent submits a property. A platform Property Approver approves or rejects.
The state machine is the single source of truth — every transition is persisted,
audited and notified.
sequenceDiagram
autonumber
participant UI as Portal UI (agent)
participant CTL as PropertyController
participant WF as PropertyWorkflowService
participant SM as PropertyStateMachineConfig
participant SVC as PropertyService
participant REPO as PropertyRepository
participant HIST as PropertyHistory
participant AUD as AuditService
participant NOTIF as NotificationService
participant APPR as Approver UI
UI->>CTL: POST /properties/{id}/submit-for-approval
CTL->>WF: submit(propertyId, currentUser)
WF->>SM: send event SUBMIT
SM-->>WF: DRAFT → PENDING_REVIEW
WF->>SVC: setStatus(PENDING_REVIEW)
SVC->>REPO: save(property)
WF->>HIST: insert (state=PENDING_REVIEW, actor=agent)
WF->>AUD: log (action=UPDATE, entityType=Property, old/new)
WF->>NOTIF: notify approvers (cross-tenant route)
WF-->>CTL: ApiResponse<PropertyResponse>
CTL-->>UI: 200 OK
APPR->>CTL: POST /properties/{id}/approve
CTL->>WF: approve(propertyId, currentUser)
WF->>SM: send event APPROVE
SM-->>WF: PENDING_REVIEW → ACTIVE
WF->>SVC: setStatus(ACTIVE), publishedAt=now()
SVC->>REPO: save(property)
WF->>HIST: insert (state=ACTIVE, actor=approver)
WF->>AUD: log (action=PUBLISH)
WF->>NOTIF: notify agency (property approved)
WF-->>CTL: ApiResponse<PropertyResponse>
CTL-->>APPR: 200 OK
Guards in the state machine reject transitions that the current role isn't allowed to perform — e.g. SUBMIT requires AGENT or higher within the property's own agency; APPROVE requires PROPERTY_APPROVER or SUPER_ADMIN globally.
Sequence C · PayHere checkout (preapproval)
A subscription upgrade. The user is redirected to PayHere's hosted form; on
success PayHere calls our webhook server-to-server. Idempotency is owned by the
database via a UNIQUE constraint on payments.order_id.
sequenceDiagram
autonumber
participant UI as Portal UI
participant CTL as PaymentController
participant SVC as PaymentService
participant HASH as PayHereHashGenerator
participant PR as PaymentRepository
participant PH as PayHere
participant SUB as SubscriptionService
participant INV as InvoiceService
participant AUD as FinancialAuditService
participant NOT as NotificationService
UI->>CTL: POST /payments/checkout-preapproval (packageId)
CTL->>SVC: initiatePreapprovalCheckout(req)
SVC->>HASH: generate orderId · md5(orderId, amount, secret)
SVC->>PR: save(Payment status=PENDING, orderId, paymentType=SUBSCRIPTION_PREAPPROVAL)
SVC-->>CTL: { redirectUrl, hash, orderId }
CTL-->>UI: 200 OK
UI->>PH: browser redirect → hosted form
PH-->>UI: success redirect (return_url)
PH->>CTL: POST /payments/notify (form-encoded)
CTL->>SVC: processNotification(payload)
SVC->>HASH: verify md5(orderId, amount, status, secret)
SVC->>PR: findByOrderIdForUpdate(orderId) // SELECT … FOR UPDATE
alt status already SUCCESS
SVC-->>CTL: idempotent ACK (no-op)
else first time
SVC->>PR: update status=SUCCESS, payherePaymentId, paidAt
SVC->>SUB: activate / upgrade subscription, store customer_token
SVC->>INV: generate invoice (async event)
SVC->>AUD: log PAYMENT_SUCCESS (amount, currency=LKR)
SVC->>NOT: notify agency (SUBSCRIPTION_RENEWED)
end
SVC-->>CTL: 200 OK (PayHere requires plain 200)
CTL-->>PH: ACK
findByOrderIdForUpdate takes a row lock to make concurrent webhook deliveries safe — PayHere retries on any 5xx, so the second delivery sees status=SUCCESS and short-circuits.
Sequence D · Recurring charge (nightly scheduler)
Once the customer token is captured, future renewals are server-driven. The
scheduler is wrapped in ShedLock so only one node fires the job in a multi-node
deployment.
sequenceDiagram
autonumber
participant CRON as @Scheduled (ShedLock)
participant LC as SubscriptionLifecycleService
participant SR as SubscriptionRepository
participant RC as PayHereRecurringService
participant OAUTH as PayHereOAuthService
participant PH as PayHere Charging API
participant PR as PaymentRepository
participant AUD as FinancialAuditService
participant NOT as NotificationService
CRON->>LC: chargeDueSubscriptions()
LC->>SR: find ACTIVE, autoRenew=true, period_end < today
loop for each subscription
LC->>RC: chargeViaToken(sub)
RC->>OAUTH: getBearerToken() (cached, 401 → refresh once)
OAUTH-->>RC: bearer
RC->>PH: POST /charge-recurring { customer_token, amount, order_id }
alt PayHere status = SUCCESS
PH-->>RC: 200 { status:2, payment_id }
RC->>PR: insert Payment(status=SUCCESS)
RC->>SR: bump period_start/end, refresh credits
RC->>AUD: log PAYMENT_SUCCESS
RC->>NOT: notify SUBSCRIPTION_RENEWED
else PayHere status = FAILED
PH-->>RC: 200 { status:-1 }
RC->>PR: insert Payment(status=FAILED)
RC->>SR: status=GRACE_PERIOD, grace_period_end=today+30
RC->>AUD: log PAYMENT_FAILED
RC->>NOT: notify PAYMENT_FAILED
else 401 from PayHere
RC->>OAUTH: clearToken(); retry once
end
end
After 30 days of GRACE_PERIOD, a separate sweeper job moves the subscription to EXPIRED and the agency's properties cascade to INACTIVE.
Sequence E · Agency sign-up + Keycloak provisioning
A new user signs up; we mint a Keycloak user, assign a realm role, and (when the
owner completes agency details) create a Keycloak organisation for the tenant.
sequenceDiagram
autonumber
participant UI as Web (signup)
participant AC as AuthController
participant AS as AuthService
participant KS as KeycloakService
participant KC as Keycloak Admin REST
participant US as UserService
participant AGS as AgencyService
UI->>AC: POST /auth/register (email, password, …)
AC->>AS: register(req)
AS->>KS: createUser(email, password)
KS->>KC: POST /admin/realms/cplk/users
KC-->>KS: 201 Created · userId (Keycloak UUID)
KS->>KC: PUT /users/{id}/role-mappings/realm (PROPERTY_OWNER initially)
KS-->>AS: identityId
AS->>US: persist User(identityId, status=ACTIVE, onboardingCompleted=false)
UI->>AC: POST /onboarding/agency (name, type, contacts)
AC->>AGS: completeOnboarding(currentUser, dto)
AGS->>KS: createOrganization(agencyName, owner=user)
KS->>KC: POST /admin/realms/cplk/organizations
KC-->>KS: 201 · orgId
AGS->>AGS: persist Agency(keycloakOrgId=orgId)
AGS->>US: link user.agencyId = agency.id, role=AGENCY_SUPER_ADMIN
AGS-->>AC: ApiResponse<AgencyResponse>
AC-->>UI: 200 OK
JWT tokens carry the user's Keycloak sub claim. JwtUtils.extractAgencyId maps that to the local users.identity_id and emits users.agency_id as the tenant key.
Sequence F · Public inquiry → tenant lead
A non-authenticated visitor submits an inquiry on a public property page. The
form is rate-limited (10/h per IP per property), and the resulting
lead is bound to the property's agency.
sequenceDiagram
autonumber
participant V as Public visitor
participant RL as RateLimitFilter
participant IC as InquiryController
participant IS as InquiryService
participant PR as PropertyRepository
participant IR as InquiryRepository
participant NOT as NotificationService
participant MAIL as EmailService
V->>RL: POST /inquiries/properties/{id} (name, email, phone, message)
RL->>RL: 10/h bucket per (ip, propertyId)
RL->>IC: forward (or 429)
IC->>IS: submit(propertyId, body, ipAddress, userAgent)
IS->>PR: findById(propertyId) // public, no tenant filter
PR-->>IS: property (carries agencyId)
IS->>IR: insert Inquiry(agencyId=property.agencyId, status=NEW)
IS->>NOT: notify agent & agency admin (INQUIRY_RECEIVED)
IS->>MAIL: send agency notification (async)
IS-->>IC: ApiResponse<InquiryResponse>
IC-->>V: 201 Created
Once the inquiry exists, every subsequent operation on it (assign, message, close) is fully tenant-isolated — only the original submission was cross-tenant by design.
Sequence G · Property image upload
sequenceDiagram
autonumber
participant UI as Portal
participant FC as FileUploadController
participant US as PropertyImageUploadService
participant ST as StorageService (R2 / Local)
participant IPS as ImageProcessingService
participant PIR as PropertyImageRepository
UI->>FC: POST /uploads (multipart) propertyId, file
FC->>US: handleUpload(file, propertyId)
US->>US: validate (mime, size 10MB, dimensions)
US->>ST: putObject(baseKey, original)
US->>PIR: save(PropertyImage baseKey, originalWidth, originalHeight)
US-->>FC: { imageId, baseKey, originalUrl }
FC-->>UI: 201 Created
par async variants
US->>IPS: generateVariants(baseKey)
IPS->>ST: put thumbnail (300×200) WebP
IPS->>ST: put medium (800×600) WebP
IPS->>IPS: strip EXIF
end
The 201 returns before variants exist. The client renders the original URL until variants land, then re-fetches the property to pick up the smaller thumbnails.
Risks & hardening recommendations
Tenancy
- Add an observability dashboard counting
@BypassTenantFilter calls per route — sudden spikes mean accidental cross-tenant reads.
- Make the bypass annotation log a structured event with caller class/method.
Payments
- Document an incident playbook for delayed PayHere webhooks (3DS held customer for hours).
- Add a reconciliation job that diffs
payments against PayHere's daily settlement report.
Workflow drift
- Capture an ADR for the property state machine — current transitions/guards are only in code.
- Add a state-coverage test that asserts every legal transition is exercised at least once.
Contract drift
- Generate
frontend/src/types/api.ts from backend OpenAPI (currently hand-maintained, 137+ DTOs).
- Add contract tests that fail when a controller returns a new DTO field that the frontend type doesn't know.