JikoXpress Pro — Financial Engine Flow
QBIT SPARK CO LIMITED | April 2026 | Internal Architecture Reference
1. Architecture Overview
The financial engine is a layered stack. Each layer only talks to the layer directly below it. No layer skips levels.
┌─────────────────────────────────────────────────────┐
│ DOMAIN LAYER (callers) │
│ CheckoutSessionService SubscriptionBillingService│
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ OrderPaymentService │
│ builds splits, decides escrow, calls TransactionSvc│
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ TransactionService │
│ records PaymentTransaction, creates splits, │
│ manages EscrowEntity, calls Ledger + Wallet │
└────────┬─────────────────────────┬──────────────────┘
│ │
┌────────▼────────┐ ┌───────────▼───────────────────┐
│ LedgerService │ │ WalletService │
│ double-entry │ │ credit/debit + WalletTxn │
│ journal entries│ │ balanceBefore/balanceAfter │
└────────▲────────┘ └───────────────────────────────┘
│
┌────────┴──────────────────────────────────────────┐
│ PSP Layer │
│ PspGatewayResolver → SnippeGateway │
│ PspCollectionService DisbursementService │
│ SnippeWebhookService │
└───────────────────────────────────────────────────┘
2. The Two Financial Worlds
| World | Channels | Treasury Involved | Platform Earns |
|---|---|---|---|
| World 1 — JikoXpress Pool | App, WhatsApp | Yes — full ledger | Commission + delivery margin |
| World 2 — Kitchen's Own | POS cash, kitchen custom, kitchen wallet | No — zero treasury | Zero per order (covered by subscription) |
Rule: If money never touches Snippe, it never touches the ledger.
3. Chart of Accounts
ASSETS — what platform physically controls
ASSET_PSP_SNIPPE money sitting at Snippe
ASSET_ESCROW held money, belongs to nobody yet
LIABILITIES — what platform owes, never touch without permission
LIABILITY_WALLETS all user wallets combined
LIABILITY_SETTLEMENTS payouts in flight, on the way out
REVENUE — platform's own earnings
REVENUE_SUBSCRIPTION_FEES kitchen plan payments
REVENUE_MARKETPLACE_COMMISSION 10% on App/WhatsApp orders
REVENUE_DELIVERY_MARGIN 30% of delivery fee
REVENUE_PROCESSING_MARGIN 0.5% on PSP transactions
EXPENSES
EXPENSE_REFUNDS money returned to customers
EQUITY
EQUITY_CAPITAL admin investments
EQUITY_RETAINED_EARNINGS accumulated profits — admin withdraws from here only
Golden Rule (checked continuously):
ASSET_PSP_SNIPPE + ASSET_ESCROW >= LIABILITY_WALLETS + LIABILITY_SETTLEMENTS
If this breaks → crisis alert immediately.
4. Money Flow — All Scenarios
4.1 Wallet Topup (Customer adds money)
Trigger: Customer initiates topup via mobile money
Customer phone
│
▼
[SnippeGateway.initiateCollection()]
│ POST /v1/payments → Snippe
│
▼
PspCollectionEntity created (status: PROCESSING)
│
│ ... customer enters PIN ...
│
▼
Snippe fires webhook: payment.completed
│
▼
[SnippeWebhookController]
│ verifies HMAC signature
│ idempotency guard via PspCollectionLogEntity
│
▼
[PspCollectionService.onPaymentCompleted()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_PSP_SNIPPE 50,000
│ CREDIT LIABILITY_WALLETS 50,000
│
├── WalletService.credit()
│ wallet.balance += 50,000
│ WalletTransactionEntity (type: TOPUP, balanceBefore, balanceAfter)
│
└── PspCollectionEntity (status: COMPLETED)
End state: Money at Snippe. Customer wallet credited. Ledger balanced.
4.2 App Order — Delivery — Mobile Money (Full escrow flow)
Order total: TZS 18,000
Customer pays via USSD
│
▼
[PspCollectionService.initiateCollection()]
│ PspCollectionEntity (status: PROCESSING)
│
▼
Snippe webhook: payment.completed
│
▼
[PspCollectionService.onPaymentCompleted()]
│ LedgerService.writeEntry()
│ DEBIT ASSET_PSP_SNIPPE 18,000
│ CREDIT LIABILITY_WALLETS 18,000 ← temporary, will move to escrow
│
▼ (collection type = ORDER_PAYMENT, wallet NOT credited here)
│
▼
[OrderPaymentService.pay()]
│ builds splits:
│ KITCHEN_REVENUE → kitchen 13,000 (PENDING)
│ DELIVERY_FEE → rider 2,800 (PENDING)
│ DELIVERY_FEE → platform 1,200 (PENDING)
│ SERVICE_FEE → platform 1,000 (PENDING)
│
▼
[TransactionService.record()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_PSP_SNIPPE 18,000
│ CREDIT ASSET_ESCROW 18,000 ← money held
│
├── PaymentTransactionEntity (holdInEscrow: true)
├── TransactionSplitEntity × 4 (status: PENDING)
└── EscrowEntity (status: HELD, condition: DELIVERY_CONFIRMED)
... kitchen prepares, rider picks up, delivers ...
▼
Rider confirms delivery
│
▼
[OrderPaymentService.confirmDelivery()]
│
▼
[TransactionService.releaseEscrow()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_ESCROW 18,000
│ CREDIT LIABILITY_WALLETS 13,000 (kitchen)
│ CREDIT LIABILITY_WALLETS 2,800 (rider)
│ CREDIT REVENUE_DELIVERY_MARGIN 1,200
│ CREDIT REVENUE_MARKETPLACE_COMMISSION 1,000
│
├── WalletService.credit(kitchenId, 13,000, ORDER_EARNING)
├── WalletService.credit(riderId, 2,800, DELIVERY_EARNING)
│
├── TransactionSplitEntity × 4 (status: CREDITED)
└── EscrowEntity (status: RELEASED)
End state: Kitchen wallet +13,000. Rider wallet +2,800. Platform revenue +2,200. Ledger balanced.
4.3 App Order — Dine-in — Mobile Money (Immediate split, no escrow)
Order total: TZS 11,000
- Kitchen: 10,000
- Commission: 1,000
Customer pays via USSD
│
▼
Snippe webhook: payment.completed
│
▼
[TransactionService.record()] holdInEscrow: false
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_PSP_SNIPPE 11,000
│ CREDIT LIABILITY_WALLETS 10,000 (kitchen)
│ CREDIT REVENUE_MARKETPLACE_COMMISSION 1,000
│
├── WalletService.credit(kitchenId, 10,000, ORDER_EARNING)
│
├── TransactionSplitEntity × 2 (status: CREDITED immediately)
└── No EscrowEntity created
End state: Money flows straight through. No holding. Kitchen credited immediately.
4.4 App Order — Pickup — Platform Wallet (Internal payment)
Order total: TZS 12,000
Customer pays from JikoXpress wallet
│ No PSP API call
│
▼
[TransactionService.record()] channel: PLATFORM_WALLET
│
├── WalletService.debit(customerId, 12,000, ORDER_PAYMENT)
│
├── LedgerService.writeEntry()
│ DEBIT LIABILITY_WALLETS 12,000 ← customer wallet reduced
│ CREDIT ASSET_ESCROW 12,000 ← held for pickup
│
├── EscrowEntity (status: HELD, condition: PICKUP_CODE_CONFIRMED)
└── TransactionSplitEntity × N (status: PENDING)
... customer arrives, shows pickup code ...
▼
[OrderPaymentService.confirmPickup()]
│
▼
[TransactionService.releaseEscrow()]
│
└── Same release flow as delivery — splits credited, escrow released
End state: Pure internal redistribution. No money entered or left the Snippe pool.
4.5 POS Cash Order (World 2 — Counter order)
Customer pays cash at counter
│
▼
[TransactionService.record()] channel: CASH
│
├── writeCollectionJournal() → returns null ← no journal entry
├── PaymentTransactionEntity created (for reporting only)
└── No splits, no escrow, no ledger movement
End state: Order recorded. Money physically in kitchen till. Treasury untouched. Platform earns zero per order.
4.6 Order Cancellation — Money in Escrow — PSP Payment
Order cancelled
│
▼
[OrderPaymentService.cancelOrder()]
│
▼
[TransactionService.refundEscrow()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_ESCROW 18,000
│ CREDIT ASSET_PSP_SNIPPE 18,000 ← back to Snippe for external refund
│
├── TransactionSplitEntity × N (status: REVERSED)
├── EscrowEntity (status: REFUNDED)
└── PaymentTransactionEntity (status: REFUNDED)
External refund to customer Mpesa handled by PSP layer separately
4.7 Order Cancellation — Wallet Payment
Order cancelled, paid via wallet
│
▼
[TransactionService.refundEscrow()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_ESCROW 12,000
│ CREDIT LIABILITY_WALLETS 12,000 ← straight back to customer wallet
│
├── WalletService.credit(customerId, 12,000, REFUND)
└── Instant. No PSP call. No waiting.
4.8 Subscription Payment — Wallet
Kitchen pays monthly subscription from wallet
│
▼
[SubscriptionBillingService.chargeViaWallet()]
│
├── WalletService.debit(accountId, 15,000, SUBSCRIPTION_PAYMENT)
│
└── LedgerService.writeEntry()
DEBIT LIABILITY_WALLETS 15,000
CREDIT REVENUE_SUBSCRIPTION_FEES 15,000
4.9 Subscription Payment — Mobile Money
Kitchen pays via Mpesa
│
▼
Snippe webhook: payment.completed
│
▼
[SubscriptionBillingService.onPaymentCompleted()]
│
└── LedgerService.writeEntry()
DEBIT ASSET_PSP_SNIPPE 15,000
CREDIT REVENUE_SUBSCRIPTION_FEES 15,000
4.10 User Withdrawal (Customer, Kitchen Owner, or Rider)
User requests withdrawal of TZS 30,000
│
▼
[DisbursementService.initiate()]
│
├── hasSufficientBalance() check
│
├── WalletService.debit(accountId, 30,000, WITHDRAWAL)
│
├── LedgerService.writeEntry() ← money earmarked
│ DEBIT LIABILITY_WALLETS 30,000
│ CREDIT LIABILITY_SETTLEMENTS 30,000
│
├── DisbursementRequestEntity (status: PENDING)
│
└── SnippeGateway.initiatePayout()
POST /v1/payouts/send → Snippe
... Snippe processes ...
▼
Snippe webhook: payout.completed
│
▼
[DisbursementService.onPayoutCompleted()]
│
├── LedgerService.writeEntry() ← money physically left
│ DEBIT LIABILITY_SETTLEMENTS 30,000
│ CREDIT ASSET_PSP_SNIPPE 30,000
│
└── DisbursementRequestEntity (status: COMPLETED)
End state: User wallet debited. Money left Snippe. Ledger balanced.
4.11 Withdrawal Failed
Snippe webhook: payout.failed
│
▼
[DisbursementService.onPayoutFailed()]
│
├── LedgerService.writeEntry() ← earmark reversed
│ DEBIT LIABILITY_SETTLEMENTS 30,000
│ CREDIT LIABILITY_WALLETS 30,000
│
├── WalletService.credit(accountId, 30,000, REVERSAL)
│
└── DisbursementRequestEntity (status: FAILED, walletRefunded: true)
End state: User wallet restored. No money lost.
4.12 Withdrawal Reversed (After Completion)
Snippe webhook: payout.reversed
│ money came back to Snippe after successful payout
│
▼
[DisbursementService.onPayoutReversed()]
│
├── LedgerService.writeEntry()
│ DEBIT ASSET_PSP_SNIPPE 30,000 ← money back at Snippe
│ CREDIT LIABILITY_WALLETS 30,000 ← returned to user
│
├── WalletService.credit(accountId, 30,000, REVERSAL)
│
└── DisbursementRequestEntity (status: REVERSED, walletRefunded: true)
4.13 Platform Offer / Subsidy Applied
Customer pays TZS 8,000. Platform covers TZS 2,000 delivery fee.
Two TransactionEntities for same order:
1. Customer: 8,000 via USSD → escrow
2. Platform: 2,000 subsidy (paidBy: PLATFORM) → escrow
On delivery confirmed:
LedgerService.writeEntry()
DEBIT ASSET_ESCROW 10,000
CREDIT LIABILITY_WALLETS 8,000 (kitchen)
CREDIT LIABILITY_WALLETS 1,400 (rider — 70% of 2,000)
CREDIT REVENUE_DELIVERY_MARGIN 600 (platform — 30% of 2,000)
CREDIT REVENUE_MARKETPLACE_COMMISSION X (commission on food amount)
SplitFundedBy.PLATFORM on delivery split — recorded for reporting
5. Idempotency Guards
Every entry point is protected against duplicates:
| Layer | Guard Mechanism |
|---|---|
| PSP Collection | PspCollectionLogEntity.providerTransid unique constraint |
| PSP Disbursement | PspDisbursementLogEntity.pspEventId unique constraint |
| Collection initiation | PspCollectionEntity.idempotencyKey unique constraint |
| Disbursement initiation | DisbursementRequestEntity.idempotencyKey unique constraint |
| Wallet debit | WalletEntity @Version optimistic lock |
| Ledger account update | LedgerAccountEntity @Version optimistic lock |
If a duplicate webhook arrives → DataIntegrityViolationException on log save → silently skipped.
6. Safety Rules
Golden Rule (continuous check)
ASSET_PSP_SNIPPE + ASSET_ESCROW >= LIABILITY_WALLETS + LIABILITY_SETTLEMENTS
Escrow Integrity Check (nightly)
ASSET_ESCROW balance = SUM(EscrowEntity WHERE status = HELD)
Balance Drift Check (nightly)
For each LedgerAccountEntity:
stored balance = recalculated from JournalEntryLines
if mismatch → alert immediately
Wallet Refund Guard
DisbursementRequestEntity.walletRefunded = true
prevents double-refund on concurrent failure callbacks
7. Disbursement Channel Lifecycle
User adds channel (phone or bank account)
│
▼
DisbursementChannelEntity
status: ACTIVE
nameVerified: false ← placeholder until Snippe name lookup available
otpVerified: true ← no OTP for now
isPrimary: true ← if first channel
│
▼
User initiates withdrawal
│
▼
DisbursementChannelService.getChannel()
channel.isUsable() check:
status == ACTIVE
otpVerified == true
deletedAt == null
│
▼
DisbursementService.initiate() proceeds
8. Data Entities — Quick Reference
| Entity | Purpose |
|---|---|
LedgerAccountEntity |
Chart of accounts — one per LedgerAccountCode |
JournalEntryEntity |
One entry per money event — append only, never updated |
JournalEntryLineEntity |
Individual debit/credit lines per journal entry |
WalletEntity |
Per-user balance + status |
WalletTransactionEntity |
Every wallet movement with balanceBefore/balanceAfter |
PspCollectionEntity |
Incoming payment request lifecycle |
PspCollectionLogEntity |
Raw webhook log — idempotency guard |
DisbursementRequestEntity |
Outgoing payout lifecycle |
DisbursementChannelEntity |
User's saved payout destinations |
PspDisbursementLogEntity |
Raw payout webhook log — idempotency guard |
PaymentTransactionEntity |
Business-level payment record per order |
TransactionSplitEntity |
Split breakdown per transaction |
EscrowEntity |
Held money with release condition |
9. What Triggers What — Event Map
Customer pays via USSD
└── Snippe webhook: payment.completed
└── SnippeWebhookService.handle()
└── PspCollectionService.onPaymentCompleted()
├── WALLET_TOPUP → WalletService.credit()
└── ORDER_PAYMENT → OrderPaymentService notified
Rider confirms delivery
└── OrderPaymentService.confirmDelivery()
└── TransactionService.releaseEscrow()
├── LedgerService.writeEntry()
└── WalletService.credit() × N recipients
Order cancelled
└── OrderPaymentService.cancelOrder()
└── TransactionService.refundEscrow()
├── LedgerService.writeEntry()
└── WalletService.credit() if wallet payment
User withdraws
└── DisbursementService.initiate()
├── WalletService.debit()
├── LedgerService.writeEntry()
└── SnippeGateway.initiatePayout()
└── Snippe webhook: payout.completed / payout.failed / payout.reversed
└── DisbursementService.onPayout*()
├── LedgerService.writeEntry()
└── WalletService.credit() if failed/reversed
QBIT SPARK CO LIMITED | JikoXpress Pro | Financial Engine Reference | April 2026 Internal document — confidential
No comments to display
No comments to display