Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/booking-vs-application.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Booking vs Application — Dual-Track Rental Flow

> mkan supports two parallel paths against the same `Listing` model. They look
> similar at the data layer but serve very different user intents. Mixing them
> in the same UI flow has been a recurring source of confusion — this doc is
> the canonical reference.

## TL;DR

| Path | Use case | Duration | Trigger | Models touched |
|------|----------|----------|---------|----------------|
| **Booking** (Airbnb-style) | Short-term stay | nights / weeks | Guest hits "Reserve" with check-in/check-out dates | `Listing` → `Booking` → `Payment` (Stripe) → `Review` |
| **Application** (lease-style) | Long-term rental | months+ | Guest hits "Apply to rent" with personal info + lease term | `Listing` → `Application` → (host approves) → `Lease` → recurring `Payment` rows |

Both paths produce a paid relationship between the host and the tenant. They
do not interoperate: a `Booking` is never converted into a `Lease`, and an
`Application` never produces a `Booking`.

## When to render which

- The listing detail page reads `Listing.rentalType` to decide which CTA the
reserve widget shows. Today the field is implicit (defaults to short-term
Booking); a future migration will make it explicit on every row.
- Hosts pick the type at listing creation time. Hosts can have a mix.

## Why both exist

mkan operates in markets (Sudan, Saudi Arabia) where Airbnb-style nightly
rentals and traditional yearly leases coexist for the same kind of property.
Forcing the two into one flow drops one or the other:

- Airbnb-style booking has no application step. Pushing tenants through one
kills conversion on weekend trips.
- Long-term lease has signed paperwork, security deposit, monthly invoicing,
and the host's right to decline. Trying to model it as a many-night
Booking loses all of that.

## Codebase entry points

| Surface | File |
|--------|------|
| Booking server actions | `src/lib/actions/booking-actions.ts` |
| Application server actions | `src/lib/actions/application-actions.ts` |
| Lease server actions | `src/lib/actions/user-actions.ts` (`getListingLeases`) |
| Payment server actions | `src/lib/actions/payment-actions.ts` |
| Review server actions | `src/lib/actions/review-actions.ts` (only the Booking path emits Reviews today) |
| Booking UI (guest) | `src/app/[lang]/bookings/[id]/...` |
| Application UI (guest) | `src/app/[lang]/(dashboard)/tenants/applications/...` |
| Lease UI (tenant) | `src/app/[lang]/(dashboard)/tenants/residences/[id]/...` |
| Manager UI | `src/app/[lang]/(dashboard)/managers/{applications,properties}/...` |

## What NOT to do

- ❌ Don't add a `bookingId` foreign key to `Application` or vice versa.
- ❌ Don't render both reserve widgets on the same listing detail page.
- ❌ Don't migrate one set of rows into the other "to clean up". They're not
duplicates — they're different relationships.
- ❌ Don't write a single `cancel()` action that handles both. The
cancellation policies differ (Stripe partial refund for Booking; lease
break-fee + remaining balance for Application).

## Followups

- **Make `rentalType` explicit on `Listing`** so the reserve widget doesn't
have to infer. Tracked in EPICS.
- **Notification model** so the host gets pinged on a new Application or a
new Booking with one event source. Tracked in EPICS.
- **Conversation/Message model** for guest↔host chat — both flows need it.
Tracked in EPICS.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"lint:fix": "eslint . --fix",
"lint:strict": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit",
"i18n:check": "bash scripts/i18n-anti-pattern-check.sh",
"wake-db": "tsx scripts/wake-db.ts",
"seed": "tsx seed.ts",
"seed:admin": "tsx scripts/seed-admin.ts",
Expand Down
24 changes: 24 additions & 0 deletions prisma/migrations/20260428103000_payments_complete/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Phase C2: payments completeness.
-- Per-office Sudan payment instructions on TransportOffice + Stripe webhook
-- idempotency log. Defaults are empty strings so existing rows continue to
-- pass `pnpm prisma migrate deploy` without explicit backfill.

ALTER TABLE "TransportOffice"
ADD COLUMN "bankName" TEXT NOT NULL DEFAULT '',
ADD COLUMN "bankAccount" TEXT NOT NULL DEFAULT '',
ADD COLUMN "bankHolder" TEXT NOT NULL DEFAULT '',
ADD COLUMN "momoNumber" TEXT NOT NULL DEFAULT '',
ADD COLUMN "momoProvider" TEXT NOT NULL DEFAULT '';

CREATE TABLE "WebhookEvent" (
"id" SERIAL PRIMARY KEY,
"provider" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventType" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"processedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "WebhookEvent_eventId_key" UNIQUE ("eventId")
);

CREATE INDEX "WebhookEvent_provider_eventType_idx" ON "WebhookEvent" ("provider", "eventType");
14 changes: 14 additions & 0 deletions prisma/migrations/20260428113000_admin_settings/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Phase C3: singleton PlatformSetting row. id is always 1 and the row is
-- created lazily by `getPlatformSettings` on first read so we don't need
-- to seed it explicitly here.

CREATE TABLE "PlatformSetting" (
"id" INTEGER PRIMARY KEY DEFAULT 1,
"platformFeePct" DOUBLE PRECISION NOT NULL DEFAULT 0.10,
"defaultCancellationPolicy" "CancellationPolicy" NOT NULL DEFAULT 'Flexible',
"supportedCurrencies" TEXT NOT NULL DEFAULT 'SDG,USD,SAR',
"payoutScheduleDays" INTEGER NOT NULL DEFAULT 30,
"emailFrom" TEXT NOT NULL DEFAULT '',
"supportEmail" TEXT NOT NULL DEFAULT '',
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
43 changes: 43 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,14 @@ model TransportOffice {
isActive Boolean @default(false)
rating Float? @default(0)
reviewCount Int @default(0)
// Per-office payment instructions for bank transfer + mobile money. Empty
// strings are allowed during onboarding; UI hides the section when blank
// so a guest never sees an empty bank-transfer card.
bankName String @default("")
bankAccount String @default("")
bankHolder String @default("")
momoNumber String @default("")
momoProvider String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buses Bus[]
Expand All @@ -488,6 +496,41 @@ model TransportOffice {
owner User @relation(fields: [ownerId], references: [id])
}

/// Stripe webhook idempotency log. Insert by `eventId @unique` before
/// processing — second delivery of the same event short-circuits and the
/// route returns 200 without re-running side effects.
model WebhookEvent {
id Int @id @default(autoincrement())
provider String
eventId String @unique
eventType String
payload Json
processedAt DateTime @default(now())

@@index([provider, eventType])
}

/// Singleton platform-level configuration. We always read/write id = 1.
/// New settings get added as columns via a one-off migration so the type
/// surface stays explicit (much easier than a KV table when six different
/// admin pages read the same handful of values).
model PlatformSetting {
id Int @id @default(1)
/// Percentage (0..1) of each booking the platform keeps. 0.10 = 10%.
platformFeePct Float @default(0.10)
/// Default cancellation policy when a host doesn't pick one explicitly.
defaultCancellationPolicy CancellationPolicy @default(Flexible)
/// Comma-separated ISO 4217 codes (e.g. "SDG,USD,SAR"). Empty = unrestricted.
supportedCurrencies String @default("SDG,USD,SAR")
/// How many days after booking the platform pays the host.
payoutScheduleDays Int @default(30)
/// Outbound email From: address (Resend / SES).
emailFrom String @default("")
/// Where bug reports / billing questions go.
supportEmail String @default("")
updatedAt DateTime @updatedAt
}

model TransportPayment {
id Int @id @default(autoincrement())
bookingId Int
Expand Down
81 changes: 81 additions & 0 deletions scripts/i18n-anti-pattern-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# i18n anti-pattern audit. Counts the things the dictionary system should own
# but that currently live inline in JSX/TS:
# 1. Inline language ternaries: lang === 'ar' ? "..." : "..."
# 2. Hardcoded English JSX text inside [lang]/* routes
# 3. Locale-naive Date/Number formatters (toLocaleDateString without args)
#
# Exits 0 if every count is at-or-below the budget, 1 otherwise. Wire as
# `pnpm i18n:check` and lefthook pre-commit so the budget can only shrink.

set -u

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" || exit 1

BUDGET_TERNARY="${I18N_BUDGET_TERNARY:-0}"
BUDGET_RAW_DATE="${I18N_BUDGET_RAW_DATE:-0}"
# Number-only `toLocaleString()` is only a real i18n bug for displayed numbers
# (Arabic-Indic digits etc.). Most call-sites today are price formatting where
# the surrounding currency literal is already locale-correct. Track but don't
# block by default — set I18N_BUDGET_RAW_NUMBER=0 once the cleanup lands.
BUDGET_RAW_NUMBER="${I18N_BUDGET_RAW_NUMBER:-100}"
# Hardcoded English JSX is harder to grep cleanly (false positives on icon
# names, prop values). Track as informational unless a budget is set.
BUDGET_HARDCODED="${I18N_BUDGET_HARDCODED:--1}"

count_lang_ternary() {
grep -rEn "lang\s*===?\s*['\"]ar['\"]\s*\?" \
src/app src/components --include="*.tsx" --include="*.ts" 2>/dev/null \
| grep -v "i18n-anti-pattern-check" \
| wc -l \
| tr -d ' '
}

count_raw_date() {
grep -rEn "toLocaleDateString\(\s*\)|toLocaleDateString\(\s*undefined\s*\)" \
src/app src/components src/lib --include="*.tsx" --include="*.ts" 2>/dev/null \
| wc -l \
| tr -d ' '
}

count_raw_number() {
grep -rEn "\.toLocaleString\(\s*\)" \
src/app src/components src/lib --include="*.tsx" --include="*.ts" 2>/dev/null \
| wc -l \
| tr -d ' '
}

count_hardcoded() {
# Heuristic: capital-letter words inside JSX text, excluding common
# technical noise (className, generateMetadata, dict.*, t(...), etc.).
grep -rEn ">\s*[A-Z][a-z]+ [A-Z]?[a-z]" \
src/app/\[lang\] --include="*.tsx" 2>/dev/null \
| grep -vE "(import|from|className|//|/\*|>\\\$\\{|dict\\.|\\bt\\()" \
| wc -l \
| tr -d ' '
}

ternary=$(count_lang_ternary)
raw_date=$(count_raw_date)
raw_number=$(count_raw_number)
hardcoded=$(count_hardcoded)

fail=0
echo "i18n anti-pattern audit"
printf " inline lang ternaries : %4s (budget %s)\n" "$ternary" "$BUDGET_TERNARY"
printf " raw toLocaleDateString calls : %4s (budget %s)\n" "$raw_date" "$BUDGET_RAW_DATE"
printf " raw .toLocaleString() calls : %4s (budget %s)\n" "$raw_number" "$BUDGET_RAW_NUMBER"
printf " hardcoded English JSX : %4s (budget %s)\n" "$hardcoded" "$BUDGET_HARDCODED"

if [ "$ternary" -gt "$BUDGET_TERNARY" ]; then fail=1; fi
if [ "$raw_date" -gt "$BUDGET_RAW_DATE" ]; then fail=1; fi
if [ "$raw_number" -gt "$BUDGET_RAW_NUMBER" ]; then fail=1; fi
if [ "$BUDGET_HARDCODED" -ge 0 ] && [ "$hardcoded" -gt "$BUDGET_HARDCODED" ]; then fail=1; fi

if [ "$fail" -eq 0 ]; then
echo " PASS"
else
echo " FAIL — counts above their budgets"
fi
exit "$fail"
12 changes: 6 additions & 6 deletions src/app/[lang]/(auth)/join/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Metadata } from "next";
import { RegisterForm } from "@/components/auth/join/form";
import { createMetadata } from "@/lib/metadata";
import { getDictionary } from "@/components/internationalization/dictionaries";
import type { Locale } from "@/components/internationalization/config";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
params: Promise<{ lang: Locale }>;
}): Promise<Metadata> {
const { lang } = await params;
const m = (await getDictionary(lang)).pageMetadata.join;
return createMetadata({
title: lang === "ar" ? "إنشاء حساب" : "Join",
description:
lang === "ar"
? "أنشئ حسابك الجديد"
: "Create your new account",
title: m.title,
description: m.description,
locale: lang,
path: "/join",
});
Expand Down
12 changes: 6 additions & 6 deletions src/app/[lang]/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Metadata } from "next";
import { LoginForm } from "@/components/auth/login/form";
import { createMetadata } from "@/lib/metadata";
import { getDictionary } from "@/components/internationalization/dictionaries";
import type { Locale } from "@/components/internationalization/config";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
params: Promise<{ lang: Locale }>;
}): Promise<Metadata> {
const { lang } = await params;
const m = (await getDictionary(lang)).pageMetadata.login;
return createMetadata({
title: lang === "ar" ? "تسجيل الدخول" : "Login",
description:
lang === "ar"
? "سجل دخولك إلى حسابك"
: "Sign in to your account",
title: m.title,
description: m.description,
locale: lang,
path: "/login",
});
Expand Down
12 changes: 6 additions & 6 deletions src/app/[lang]/(auth)/reset/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Metadata } from "next";
import { ResetForm } from "@/components/auth/reset/form";
import { createMetadata } from "@/lib/metadata";
import { getDictionary } from "@/components/internationalization/dictionaries";
import type { Locale } from "@/components/internationalization/config";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
params: Promise<{ lang: Locale }>;
}): Promise<Metadata> {
const { lang } = await params;
const m = (await getDictionary(lang)).pageMetadata.reset;
return createMetadata({
title: lang === "ar" ? "إعادة تعيين كلمة المرور" : "Reset Password",
description:
lang === "ar"
? "أعد تعيين كلمة المرور الخاصة بك"
: "Reset your password",
title: m.title,
description: m.description,
locale: lang,
path: "/reset",
});
Expand Down
7 changes: 5 additions & 2 deletions src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@
import { useParams, usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useDictionary } from "@/components/internationalization/dictionary-context";
import { useLocale } from "@/components/internationalization/use-locale";
import { formatDate } from "@/lib/i18n/formatters";

const PropertyManagement = () => {
const { id } = useParams();
const pathname = usePathname();
const isAr = pathname?.startsWith("/ar");
const dict = useDictionary();
const { locale: lang } = useLocale();
const propertyId = Number(id);

const [property, setProperty] = useState<any>(null);

Check warning on line 35 in src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
const [leases, setLeases] = useState<any[]>([]);

Check warning on line 36 in src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
const [payments, setPayments] = useState<any[]>([]);

Check warning on line 37 in src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

Expand All @@ -54,7 +57,7 @@
};
}

let leasesData: any[] = [];

Check warning on line 60 in src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
try {
leasesData = await getListingLeases(propertyId);
} catch (leaseError) {
Expand Down Expand Up @@ -82,7 +85,7 @@
setPayments([]);
}
}
} catch (err: any) {

Check warning on line 88 in src/app/[lang]/(dashboard)/dashboard/properties/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
console.error("Error fetching data:", err);
setError(err.message || "Error loading property details");
} finally {
Expand Down Expand Up @@ -185,9 +188,9 @@
</TableCell>
<TableCell>
<div>
{new Date(lease.startDate).toLocaleDateString()} -
{formatDate(lease.startDate, lang)} -
</div>
<div>{new Date(lease.endDate).toLocaleDateString()}</div>
<div>{formatDate(lease.endDate, lang)}</div>
</TableCell>
<TableCell>${lease.rent.toFixed(2)}</TableCell>
<TableCell>
Expand Down
Loading
Loading