Propai CRM

Propai CRM

A multi-tenant SaaS platform for real estate agencies.

Feb 2026 – Apr 2026
Node.jsNode.jsNestJSNestJSTypeScriptTypeScriptMongoDBMongoDBMongooseMongooseJWTJWTPostgreSQLPostgreSQLPaymobPaymob

Propai (a portmanteau of 'Property' & 'AI') CRM is a multi-tenant SaaS platform built for the Egyptian real estate market. Each real estate company that subscribes gets their own isolated CRM instance, their own database, their own subdomain, their own users, all provisioned automatically the moment a payment clears.

The platform is made up of five independent services that work together:

ServiceTechnologyRole
CRM FrontendNext.js + TanStack Query + ZustandThe frontend used by real-estate agents & sales people in their day-to-day work
CRM BackendNestJS + Prisma + MongoDB + PostgreSQLPer-tenant application server: handles CRM logic and tenant provisioning
Dashboard BackendNestJS + Prisma + MongoDBCentral billing authority: owns all subscriptions, invoices, and payments
Dashboard FrontendNext.jsAdmin panel for managing packages, coupons, and company oversight
CRM Landing PageNext.js + TanStack QueryThe landing page where new customers can review what our CRM might add to their business, and can start their subscription.

I joined the Rockaidev team midway through the project in a period where the management wanted to automate billing and migrate away from the hassle of cash payments, so I took full ownership of the subscription module, payment gateway integration, and the service-to-service authentication layer. Below are the pieces of the system I'm most proud of.


Engineering Highlights

1. The Dual-Subscription Billing Model

Every tenant in Propai CRM carries exactly two active subscriptions simultaneously:

  • PLATFORM — A flat annual access fee. This is the "license to use the software." It always runs for exactly one year regardless of what plan the company is on.
  • PACKAGE — The per-user seat cost tied to the chosen plan. This can be billed monthly, quarterly, semi-annually, or annually. The pricing formula is:
Total = PlatformFee + (costPerUser × durationMonths × userCount) − Discount

The two subscriptions are created together at checkout and activated together when payment clears, but they can be renewed independently. A company might renew their package to a different plan while the platform fee still has 6 months remaining. The system tracks them separately in the database and handles every combination cleanly.

One subtle but important detail I implemented: the createSubscription() flow contains a 29-entry reserved subdomain list (www, admin, api, billing, docs, etc.) that rejects registrations before they get anywhere near the payment flow. It also handles the case where a company starts checkout, abandons mid-payment, and comes back later: rather than creating a duplicate record, the system detects the pending company by name or subdomain and resumes from where it left off, deleting the stale pending subscriptions and invoice before starting fresh.


2. Three Steps to Paymob

Propai never touches card numbers. The user's card details go directly to Paymob on Paymob's own hosted page. Our job is to set up a secure handshake with Paymob before the user gets there, and then listen for the result.

Paymob's integration follows a 3-step protocol, all implemented in paymob.gateway.ts:

// Step 1: Exchange API key for a session token
const authToken = await paymobClient.authenticate();
 
// Step 2: Register the order with line items
// Discounts are split proportionally across items:
// itemDiscount = (itemAmount / totalBeforeDiscount) * totalDiscount
const orderId = await paymobClient.createOrder(authToken, amountCents, items);
 
// Step 3: Generate the payment key
const paymentToken = await paymobClient.createPaymentKey(
  authToken,
  amountCents,
  orderId,
);
 
return `https://accept.paymob.com/api/acceptance/iframes/${this.iframeId}?payment_token=${paymentToken}`;

The user is redirected to this URL, enters their card details on Paymob's secure hosted page, and then Paymob sends a webhook back to us. At that point, processSuccessfulPayment() runs: it creates the payment record, activates both pending subscriptions, marks the company active, generates the invoice and contract PDFs in parallel, sends bilingual confirmation emails to both the company email and the representative email, and triggers tenant provisioning. If provisioning fails, a Slack alert fires to DevOps automatically.


3. The Renewal Engine

Renewing a subscription sounds simple, but the hard part is making sure there are no gaps in service and no accidental overlaps in payment. If you renew a week early, your new subscription shouldn't start until your current one expires. If you forgot to renew and it's already expired, it should start today.

Handling early renewal - while complex - encourages CRM customers to pay early and never miss a bill, which also allowed for a more consistent cash-flow for our company.

All four renewal paths (yearly, platform-only, package/upgrade, seat-increase) share the same core start-date logic:

// max(currentSub.endDate, now) — so subscriptions always chain, never gap
const newStartDate = new Date(
  Math.max(latestPackageSubscription.endDate.getTime(), Date.now()),
);

This one line eliminates an entire class of billing bugs. Early renewal queues seamlessly. Late renewal starts today. No manual date adjustment, no admin intervention.

Mid-cycle seat increases use a prorated pricing model: for annual subscriptions, the charge is costPerUser × remainingMonths × addedSeats where remainingMonths is calculated day-accurately using daysRemaining / 30.44. For monthly subscriptions there's a midpoint rule: if the company is past the 15th of the month, they're charged only 50% for the added seats since there's less than half the cycle left.


4. Zero-Risk Webhooks

Paymob sends us a message when a payment succeeds or fails. But anyone on the internet could send us that same type of message pretending to be Paymob. We need a way to verify that the message is genuinely from Paymob and hasn't been tampered with in transit.

Every Paymob webhook is verified using HMAC-SHA512 before any processing occurs. The verification requires concatenating 20 specific transaction fields — in an exact alphabetical order that Paymob defines — into a single string, then computing the SHA-512 HMAC digest of that string using a shared secret. If a single field is wrong, missing, or in a different order, the hash won't match and the webhook is silently rejected with a 401.

private verifyWebhookSignature(data: PaymobWebhookDto): boolean {
  const components = [
    obj.amount_cents, obj.created_at, obj.currency, obj.error_occured,
    obj.has_parent_transaction, obj.id, obj.integration_id, obj.is_3d_secure,
    obj.is_auth, obj.is_capture, obj.is_refunded ?? false,
    obj.is_standalone_payment, obj.is_voided ?? false, orderId,
    obj.owner, obj.pending,
    obj.source_data?.pan || '', obj.source_data?.sub_type || '',
    obj.source_data?.type || '', obj.success
  ];
  const hmacString = components
    .map(v => v === null || v === undefined ? '' : v.toString())
    .join('');
  const hash = crypto.createHmac('sha512', this.webhookSecret)
    .update(hmacString).digest('hex');
  return hash === data.hmac;
}

One subtle implementation note: null and undefined fields are coerced to empty strings, not the literal string "undefined" (which is what template literals would produce). Getting this wrong would cause legitimate webhooks to fail verification, a bug I caught and fixed during testing.

I also built a manual verification fallback for the rare case where a webhook is lost in transit. syncPaymentStatus() queries Paymob's transaction_inquiry API using the stored order ID, constructs a synthetic webhook payload from the response, and passes it through the same processSuccessfulPayment() path. The user hits "check payment status" and everything resolves without any manual intervention.


5. JWS-in-JWE Service Auth

The Dashboard Backend and the CRM Backend need to talk to each other securely, but without a logged-in user between them. We also needed a reliable way to know which company's data was being requested across completely isolated databases. We solved this with a UUID that acts as a a globally unique identifier for each company, combined with a cryptographic handshake that makes every system-to-system message both signed and encrypted.

Every API call between the two services carries a JWS-in-JWE token: a signed JWT (proof of identity) wrapped inside an encrypted envelope (confidentiality). Each service holds its own RSA private key for signing and the other service's RSA public key for encrypting, four keys total.

// crm-auth.service.ts — Dashboard signs and encrypts outbound tokens to CRM
const jwt = await new jose.SignJWT({
  iss: "dashboard-backend",
  aud: "crm-backend",
  purpose: "system-to-system-auth",
  jti: crypto.randomUUID(), // unique ID for replay protection
})
  .setProtectedHeader({ alg: "RS256" })
  .setExpirationTime("2m") // short-lived — only valid for the next 2 minutes
  .sign(DASHBOARD_PRIVATE_KEY);
 
const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(jwt))
  .setProtectedHeader({ alg: "RSA-OAEP-256", enc: "A256GCM" })
  .encrypt(CRM_PUBLIC_KEY);

The CRM side is a perfect mirror: it signs with CRM_PRIVATE_KEY and encrypts to DASHBOARD_PUBLIC_KEY. Both implementations were built symmetrically to make auditing straightforward.

Replay protection is handled by a JtiStoreService: an in-memory store that records every JTI it has ever seen. If the same token arrives twice within its 2-minute validity window, it's rejected immediately. The store is garbage-collected every 5 minutes via a cron job.

I also designed a two-tier guard system for the Dashboard Backend's endpoints:

  • ActiveCompanyGuard — verifies the JWE and checks whether the company's subscription is currently active. Used on read endpoints where data access requires an active subscription.
  • CompanyGuard — verifies the JWE and identifies the company, but does not check subscription status. Used on renewal & subscription data endpoints, so a company whose subscription has lapsed can still pay to renew it.

This distinction is what makes renewals work correctly for expired tenants without punching holes in security elsewhere.


6. Tenant Provisioning

The moment a payment succeeds, we need to build an entire CRM instance for a company that didn't exist in our system 5 minutes ago. That means creating a database, setting it up with all the right tables, creating their admin login, registering their subdomain on the internet, and making it all live — ideally before the user finishes reading the payment confirmation page.

Provisioning steps:

  1. Database provisioning.
  2. Schema migration and seeding.
  3. Tenant registration.
  4. Runtime configuration.
  5. Kubernetes ingress and DNS setup.
  6. AI service initialization.

This is the part of the system I find most satisfying to explain: a company pays, and within seconds they have a live, isolated, fully-migrated CRM at their own subdomain.