Series: Ejar Registration Readiness
At Amlaki we designed a full path for registering lease contracts through Saudi Arabia's government Ejar platform. This post covers the engineering decisions in the request lifecycle — from validation through registration, rejection, or retry.
The previous post in this series covered the validation layer itself. This one covers what comes after: the state machine, the money, and the permissions.
Disclosure: Amlaki has not yet joined the Ejar digital integration program. The path described here is a registration-request flow executed through a licensed broker, preceded by a data readiness layer.
Context
Ejar offers an official digital integration service for real estate platforms, and has concluded 12 integration agreements. But whichever path you take — direct integration or a broker — one constant holds: Ejar verifies identities through its links to the Ministry of Interior and the National Information Center, and wrong data is rejected.
The entire workflow is built around that fact.
Contract in the system
|
Readiness check (client-side, live)
|
Create request --> 400 + list of gaps if incomplete
|
Compute fees -> debit wallet -> PENDING
|
IN_PROGRESS
|
+-- COMPLETED (requires an Ejar contract number)
+-- REJECTED (requires a reason -> automatic refund -> retry available)
1. The state machine: four states, no more
The temptation is to add a state for every nuance: DATA_READY, SUBMITTED, PENDING_PARTIES, PARTIALLY_SIGNED... We resisted.
enum EjarRequestStatus {
PENDING // awaiting processing
IN_PROGRESS // being processed
COMPLETED // registered
REJECTED // rejected
}
The rule we used: a state earns its existence if system behavior differs in it — not if its description differs.
DATA_READY, for instance, doesn't earn one: data readiness isn't a phase the request passes through, it's a precondition for the request existing at all. A request is only created when its data is complete, so a "data is ready" state means nothing.
And every extra state means a branch in every query, a column in every dashboard, and a case you must think about in every migration.
Both terminal states are explicitly guarded:
if (request.status === 'COMPLETED') throw new BadRequestException('Request is already completed');
if (request.status === 'REJECTED') throw new BadRequestException('Request is already rejected');
Without those two lines, two concurrent admin actions can produce two refunds for the same amount.
2. The gate comes before the money
The order of operations at creation isn't arbitrary:
// 1. Validate completeness — before anything that costs money
const missingFields = this.validateContractData(contract);
if (missingFields.length > 0) {
throw new BadRequestException({
message: 'Contract data is incomplete for electronic registration',
missingFields,
});
}
// 2. Compute fees
const durationYears = this.calculateDurationYears(contract.startDate, contract.endDate);
const fees = this.calculateFee(dto.propertyUsageType, durationYears);
// 3. Debit
const { transaction: walletTx } = await this.wallet.debit(
agencyId,
fees.totalFee,
`Electronic contract fee - contract ${contract.contractNumber}`,
user.id,
'EJAR_REQUEST',
);
// 4. Create the request
const ejarRequest = await this.prisma.ejarContractRequest.create({
data: {
contractId: contract.id,
agencyId,
requestedById: user.id,
propertyUsageType: dto.propertyUsageType as any,
contractDurationYears: durationYears,
ejarFee: fees.ejarFee,
brokerFee: fees.brokerFee,
serviceFee: fees.serviceFee,
totalFee: fees.totalFee,
status: 'PENDING',
walletTransactionId: walletTx.id,
},
});
// 5. Back-link the wallet transaction to the request
await this.prisma.agencyWalletTransaction.update({
where: { id: walletTx.id },
data: { referenceId: ejarRequest.id },
});
Why missingFields in the exception body? Because a bare 400 Bad Request makes the UI render "something went wrong." The list lets it render "Missing: tenant birth date, deed number" — the difference between a support ticket and a user finishing their work.
Why is the back-link a separate step? Because the wallet transaction is created before the request, so it can't know the request's id yet. Step 5 closes the loop, making every line in the wallet statement traceable to its cause.
Duration treats a partial year as a full year, matching official pricing:
calculateDurationYears(startDate: Date, endDate: Date): number {
const diffMs = endDate.getTime() - startDate.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return Math.ceil(diffDays / 365);
}
Math.ceil here is a business decision, not a technical one — a 13-month contract prices as two years. Had we used Math.round, the invoice would differ, and we'd have learned about it from a customer complaint rather than a test.
3. Permissions: who owns the request?
The system is multi-agency with several user types. The check at creation:
const agencyId = contract.unit?.property?.agencyId;
if (!agencyId) throw new BadRequestException('Cannot determine the agency for this contract');
if (user.userType !== 'SUPER_ADMIN') {
if (user.userType === 'AGENCY_ADMIN' && user.agency?.id !== agencyId) {
throw new ForbiddenException('You do not have permission for this contract');
}
if (
(user.userType === 'OWNER' || user.userType === 'LINKED_OWNER') &&
contract.unit?.property?.ownerId !== user.owner?.id
) {
throw new ForbiddenException('You do not have permission for this contract');
}
}
And on reads, the filter is built into where rather than applied after fetching:
const where: any = {};
if (filters.status) where.status = filters.status;
if (user.userType === 'AGENCY_ADMIN') {
where.agencyId = user.agency?.id;
} else if (user.userType !== 'SUPER_ADMIN') {
throw new ForbiddenException('You do not have permission to view requests');
}
The important point: tenant isolation belongs in the query predicate — not in post-fetch filtering, and certainly not in UI hiding. Fetching all requests and filtering in memory means any later bug in the filter path leaks one agency's data to another — and worse, pagination becomes silently wrong.
Note the first line too: agencyId is derived from the property, not from the user. The user states what they want; the data states who it belongs to.
4. Rejection: the refund is part of the transition
The most common mistake I've seen in similar flows: rejection updates the status, and the refund is "handled later." The result is balances that don't reconcile.
Ours lives inside the same transition:
if (dto.status === 'REJECTED') {
if (!dto.rejectionReason) throw new BadRequestException('Rejection reason is required');
updateData.rejectionReason = dto.rejectionReason;
updateData.rejectedAt = new Date();
const { transaction: refundTx } = await this.wallet.refund(
request.agencyId,
Number(request.totalFee),
`Refund for rejected electronic contract - contract ${request.contract.contractNumber}`,
user.id,
'EJAR_REQUEST_REFUND',
request.id,
);
updateData.refundTransactionId = refundTx.id;
// Clear the request flag on the contract
await this.prisma.contract.update({
where: { id: request.contractId },
data: { ejarRequested: false },
});
}
Conversely, completion requires what the state is meaningless without:
if (dto.status === 'COMPLETED') {
if (!dto.ejarContractNumber) throw new BadRequestException('Ejar contract number is required');
updateData.ejarContractNumber = dto.ejarContractNumber;
updateData.completedAt = new Date();
await this.prisma.contract.update({
where: { id: request.contractId },
data: { ejarContractNumber: dto.ejarContractNumber },
});
}
The general rule: if a state implies the existence of data, make that data a condition of transitioning into it. A COMPLETED request with no contract number is a lying row — it claims "registered" and can't prove it.
And Number(request.totalFee) isn't redundant: Prisma's Decimal isn't a JavaScript number, and passing it straight into a function expecting one is a source of silent bugs.
5. Retry versus the unique constraint
contractId @unique prevents two requests per contract — which is what we want. But a rejected request must be retryable, or a user's contract gets stuck because of an administrative error.
The two constraints appear to conflict. The resolution:
// Allow re-requesting if the previous request was rejected
if (contract.ejarRequest && contract.ejarRequest.status !== 'REJECTED') {
throw new BadRequestException('An electronic contract request already exists for this contract');
}
// Delete the old rejected request (contractId is unique)
if (contract.ejarRequest && contract.ejarRequest.status === 'REJECTED') {
await this.prisma.ejarContractRequest.delete({ where: { id: contract.ejarRequest.id } });
}
The trade-off is explicit: we lose the rejected request's row, and keep its trace in the activity log. The alternatives — a composite key on (contractId, status), an attempt counter, or deletedAt soft deletes — preserve fuller history at the cost of complexity in every query.
We chose simplicity because "how many times was this contract rejected?" wasn't a real question for our users. If it becomes one, the migration is straightforward.
This is a general pattern: a unique constraint prevents duplicates, and retries need an escape hatch from it. Decide which price you're paying — complexity or history.
Engineering decisions, condensed
1. A state earns existence if system behavior differs in it, not if its description does. Four states suffice.
2. The gate precedes the money. Validate, then compute, then debit — in that order.
3. Return gaps by name in the error body. A bare 400 produces a support ticket.
4. Tenant isolation belongs in where, not in post-fetch filtering. And derive ownership from the data, not the request.
5. Money is part of the state transition. The refund on rejection isn't a deferred job.
6. Require a state's data before transitioning into it. COMPLETED without a contract number is a lying row.
7. Unique constraints and retries conflict — resolve the trade-off explicitly and document it.
8. Don't claim an integration you haven't joined. "Ejar data readiness" ≠ "integrated with Ejar."
Stack
NestJS · Prisma · PostgreSQL · Next.js · React Native
Official sources: Ejar platform (ejar.sa) | Real Estate General Authority (rega.gov.sa)
Built at amlakire.com
Top comments (0)