Engineering · wire-level

Deep architecture & sequences.

The flows that bind the system together. Read these top to bottom when reviewing a change that touches auth, payments, or property workflow — and when on call.

Codebase-derived evidence

Every flow on this page is sourced directly from these files:

AreaSourceArchitectural meaning
Security chainbackend/src/main/java/com/cplk/api/config/SecurityConfig.javaJWT resource server · route allowlist · rate-limit & tenant filters in chain
Tenant isolationtenant/TenantFilter.java · TenantFilterAspect.java · TenantAwareEntity.java · BypassTenantFilter.javaJWT claim → ThreadLocal → Hibernate filter on every repo call
Property workflowconfig/PropertyStateMachineConfig.java · property/PropertyWorkflowService.javaState machine · role-gated transitions · history + audit + notify
Payment lifecyclepayment/PaymentController.java · PaymentService.java · PayHereRecurringService.java · PayHereHashGenerator.javaWebhook-driven idempotent status changes · preapproval token · recurring charges
Frontend clientfrontend/src/lib/api.ts · frontend/src/services/*.tsAxios 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.