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.

Sequences 7 diagrams Max participants 6 per diagram Tenant clear finally block Idempotency DB UNIQUE On-call read top to bottom

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