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 F as Filter Chain
participant C as Controller
participant S as Service
participant R as Repository
participant DB as PostgreSQL
B->>F: GET /api/properties · Bearer JWT
Note over F: JWT validate · rate-limit
TenantContext.set(agencyId)
F->>C: forward · @PreAuthorize
C->>S: search(filters)
S->>R: findAll(spec, page)
Note over R: aspect enables
Hibernate tenantFilter
R->>DB: SELECT … WHERE agency_id = ?
DB-->>R: rows
R-->>S: page<Property>
S-->>C: PagedResponse (MapStruct)
C-->>B: 200 OK · ApiResponse<T>
Note over F: finally → TenantContext.clear()
Filter Chain bundles SecurityFilterChain, RateLimitFilter and TenantFilter. Even a SUPER_ADMIN traverses this path — cross-tenant access requires an explicit @BypassTenantFilter on the called method.
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 AG as Agent UI
participant C as Controller
participant WF as WorkflowService
participant SM as State Machine
participant FX as Side-effects
participant AP as Approver UI
AG->>C: POST /properties/{id}/submit-for-approval
C->>WF: submit(propertyId, user)
WF->>SM: event SUBMIT
SM-->>WF: DRAFT → PENDING_REVIEW
WF->>FX: save · history · audit(UPDATE) · notify approvers
WF-->>C: ApiResponse<PropertyResponse>
C-->>AG: 200 OK
AP->>C: POST /properties/{id}/approve
C->>WF: approve(propertyId, user)
WF->>SM: event APPROVE
SM-->>WF: PENDING_REVIEW → ACTIVE
WF->>FX: save(publishedAt) · history · audit(PUBLISH) · notify agency
WF-->>C: ApiResponse<PropertyResponse>
C-->>AP: 200 OK
Side-effects = PropertyService.save + PropertyHistory + AuditService + NotificationService, all inside the same transaction. State-machine guards reject transitions the current role can't perform: SUBMIT needs AGENT+ in-tenant; APPROVE needs PROPERTY_APPROVER / 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 API as Payment API
participant DB as Payment Repo
participant PH as PayHere
participant FX as Side-effects
UI->>API: POST /payments/checkout-preapproval
API->>DB: save Payment(PENDING, orderId, md5 hash)
API-->>UI: redirectUrl · hash · orderId
UI->>PH: browser redirect → hosted form
PH-->>UI: success redirect (return_url)
PH->>API: POST /payments/notify (webhook)
API->>DB: findByOrderIdForUpdate (row lock)
alt status already SUCCESS
API-->>PH: idempotent ACK
else first time
API->>DB: update SUCCESS · payherePaymentId · paidAt
API->>FX: activate sub · store token · invoice · audit · notify
end
API-->>PH: 200 OK (plain text)
Payment API = PaymentController + PaymentService + PayHereHashGenerator. Side-effects = SubscriptionService + InvoiceService + FinancialAuditService + NotificationService. The FOR UPDATE row lock + UNIQUE order_id make webhook retries idempotent — PayHere retries on any 5xx, so the second delivery sees 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 CR as ShedLock Scheduler
participant SL as Lifecycle Service
participant RC as Recurring Charger
participant PH as PayHere
participant FX as Side-effects
CR->>SL: chargeDueSubscriptions()
SL->>SL: find ACTIVE · autoRenew · period_end < today
loop for each subscription
SL->>RC: chargeViaToken(sub)
RC->>PH: POST /charge-recurring (token, amount, orderId)
alt SUCCESS
PH-->>RC: 200 · status=2 · payment_id
RC->>FX: Payment(SUCCESS) · bump period · refresh credits · audit · notify
else FAILED
PH-->>RC: 200 · status=-1
RC->>FX: Payment(FAILED) · status=GRACE_PERIOD · audit · notify
else 401 (token expired)
RC->>RC: clear OAuth token · retry once
end
end
Lifecycle Service drives the loop; Recurring Charger wraps PayHereRecurringService + PayHereOAuthService; Side-effects = PaymentRepository + SubscriptionRepository + FinancialAuditService + NotificationService. After 30 days in GRACE_PERIOD a 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 AS as AuthService
participant KS as Keycloak Client
participant KC as Keycloak
participant AGS as AgencyService
UI->>AS: POST /auth/register (email, password)
AS->>KS: createUser(email, password)
KS->>KC: POST /admin/realms/cplk/users
KC-->>KS: 201 · userId (Keycloak UUID)
KS->>KC: assign realm role PROPERTY_OWNER
KS-->>AS: identityId
AS->>AS: persist User(identityId, ACTIVE, onboarding=false)
UI->>AGS: POST /onboarding/agency (name, type, contacts)
AGS->>KS: createOrganization(agencyName, owner=user)
KS->>KC: POST /admin/realms/cplk/organizations
KC-->>KS: 201 · orgId
AGS->>AGS: persist Agency(keycloakOrgId=orgId)
AGS->>AGS: link user.agencyId · role=AGENCY_SUPER_ADMIN
AGS-->>UI: 200 OK · AgencyResponse
Keycloak Client = KeycloakService (server-side Admin REST wrapper). JWT tokens carry the user's Keycloak sub claim; JwtUtils.extractAgencyId maps that to 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 Visitor
participant RL as Rate Limit
participant API as Inquiry API
participant DB as Repos
participant FX as Notify + Email
V->>RL: POST /inquiries/properties/{id}
RL->>RL: 10/h bucket per (ip, propertyId)
RL->>API: forward (or 429)
API->>DB: findById(propertyId) · no tenant filter
DB-->>API: property (carries agencyId)
API->>DB: insert Inquiry(agencyId, status=NEW)
API->>FX: notify agent + admin · async email
API-->>V: 201 Created
Inquiry API = InquiryController + InquiryService. 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 API as Upload API
participant ST as Storage (R2)
participant DB as Image Repo
participant IPS as Image Processor
UI->>API: POST /uploads (multipart, propertyId)
API->>API: validate (mime, size 10MB, dimensions)
API->>ST: putObject(baseKey, original)
API->>DB: save(PropertyImage, dimensions)
API-->>UI: 201 · imageId · baseKey · originalUrl
par async variants
API->>IPS: generateVariants(baseKey)
IPS->>ST: thumbnail 300×200 WebP · medium 800×600 WebP
IPS->>IPS: strip EXIF
end
Upload API = FileUploadController + PropertyImageUploadService. 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.