diff --git a/apps/api/prisma/schema/migrations/20260908000000_add_withdrawal_transaction_type/migration.sql b/apps/api/prisma/schema/migrations/20260908000000_add_withdrawal_transaction_type/migration.sql
new file mode 100644
index 0000000..2680f12
--- /dev/null
+++ b/apps/api/prisma/schema/migrations/20260908000000_add_withdrawal_transaction_type/migration.sql
@@ -0,0 +1 @@
+ALTER TYPE "TransactionType" ADD VALUE 'withdrawal';
diff --git a/apps/api/prisma/schema/transaction.prisma b/apps/api/prisma/schema/transaction.prisma
index fedb1e8..85a2a8b 100644
--- a/apps/api/prisma/schema/transaction.prisma
+++ b/apps/api/prisma/schema/transaction.prisma
@@ -2,6 +2,7 @@ enum TransactionType {
topup
session_charge
earning
+ withdrawal
}
model Transaction {
diff --git a/apps/api/src/common/utils/withdrawal-metadata.ts b/apps/api/src/common/utils/withdrawal-metadata.ts
new file mode 100644
index 0000000..2cfb37f
--- /dev/null
+++ b/apps/api/src/common/utils/withdrawal-metadata.ts
@@ -0,0 +1,88 @@
+import { createHash } from "node:crypto";
+import type { Transaction } from "@prisma/client";
+import { z } from "zod";
+
+// Supported mock destinations, not bank ownership or bank-specific account verification.
+export const THAI_BANK_CODES = [
+ "BBL",
+ "KBANK",
+ "KTB",
+ "SCB",
+ "BAY",
+ "TTB",
+ "GSB",
+ "BAAC",
+ "GHB",
+ "KKP",
+ "TISCO",
+ "CIMBT",
+ "UOB",
+] as const;
+
+const metadataSchema = z
+ .object({
+ kind: z.literal("withdrawal"),
+ version: z.literal(1),
+ requestId: z
+ .string()
+ .uuid()
+ .regex(/^[0-9a-f-]+$/),
+ requestFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
+ currency: z.literal("THB"),
+ status: z.literal("completed"),
+ destination: z
+ .object({
+ bankCode: z.enum(THAI_BANK_CODES),
+ accountNumberMasked: z.string().regex(/^\*{2,16}\d{4}$/),
+ accountHolderName: z
+ .string()
+ .min(1)
+ .max(100)
+ .refine((name) => name === name.trim().replace(/\s+/g, " ")),
+ })
+ .strict(),
+ balanceAfter: z.string().regex(/^(0|[1-9]\d{0,9})\.\d{2}$/),
+ })
+ .strict();
+
+export type WithdrawalMetadata = z.infer;
+
+// UUIDv5 with the fixed standard URL namespace. No secret or amount in the ID:
+// changing details must collide with the original request, never create another debit.
+export function withdrawalTransactionId(userId: string, requestId: string): string {
+ const namespace = Buffer.from("6ba7b8119dad11d180b400c04fd430c8", "hex");
+ const hash = createHash("sha1")
+ .update(namespace)
+ .update(`tutorist:withdrawal:${userId}:${requestId.toLowerCase()}`, "utf8")
+ .digest();
+ hash[6] = (hash[6] & 0x0f) | 0x50;
+ hash[8] = (hash[8] & 0x3f) | 0x80;
+ const hex = hash.subarray(0, 16).toString("hex");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+export function serializeWithdrawalMetadata(metadata: WithdrawalMetadata): string {
+ return JSON.stringify(metadataSchema.parse(metadata));
+}
+
+export function readWithdrawalMetadata(transaction: Transaction): WithdrawalMetadata | null {
+ if (
+ transaction.type !== "withdrawal" ||
+ transaction.relatedSessionId !== null ||
+ !transaction.amount.greaterThan(0) ||
+ transaction.note === null
+ )
+ return null;
+ try {
+ const result = metadataSchema.safeParse(JSON.parse(transaction.note));
+ if (
+ !result.success ||
+ transaction.id !== withdrawalTransactionId(transaction.userId, result.data.requestId)
+ ) {
+ return null;
+ }
+ return result.data;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/api/src/controller/withdrawal.controller.ts b/apps/api/src/controller/withdrawal.controller.ts
new file mode 100644
index 0000000..1d3da20
--- /dev/null
+++ b/apps/api/src/controller/withdrawal.controller.ts
@@ -0,0 +1,87 @@
+import { Prisma } from "@prisma/client";
+import type { NextFunction, Response } from "express";
+import { z } from "zod";
+import type { AuthRequest } from "../common/middleware/auth.middleware.js";
+import { ForbiddenError, UnauthorizedError } from "../common/errors/app-error.js";
+import { successResponse } from "../common/utils/response.js";
+import { withdrawalService } from "../service/withdrawal.service.js";
+import { THAI_BANK_CODES } from "../common/utils/withdrawal-metadata.js";
+
+export const withdrawalSchema = z
+ .object({
+ requestId: z
+ .string()
+ .uuid("Request ID must be a UUID")
+ .transform((id) => id.toLowerCase()),
+ amount: z
+ .number()
+ .finite()
+ .positive("Withdrawal amount must be greater than zero")
+ .max(9999999999.99, "Withdrawal amount exceeds the supported precision")
+ .refine(
+ (amount) => new Prisma.Decimal(amount).decimalPlaces() <= 2,
+ "Withdrawal amount must have at most two decimal places"
+ ),
+ password: z.string().min(1, "Confirmation password is required"),
+ bankCode: z.enum(THAI_BANK_CODES, { message: "Please select a supported Thai bank" }),
+ accountNumber: z
+ .string()
+ .max(64)
+ .trim()
+ .transform((value) => value.replace(/[ -]/g, ""))
+ .pipe(z.string().regex(/^\d{6,20}$/, "Bank account number must contain 6 to 20 digits")),
+ accountHolderName: z
+ .string()
+ .trim()
+ .min(1, "Account holder name is required")
+ .max(100)
+ .transform((name) => name.replace(/\s+/g, " ")),
+ })
+ .strict();
+
+function tutorId(req: AuthRequest) {
+ if (!req.user) throw new UnauthorizedError();
+ if (req.user.role !== "tutor") throw new ForbiddenError("Only tutors can withdraw balance");
+ return req.user.id;
+}
+
+export const withdrawalController = {
+ async getByRequest(req: AuthRequest, res: Response, next: NextFunction) {
+ try {
+ const userId = tutorId(req);
+ const requestId = z.string().uuid("Request ID must be a UUID").parse(req.params.requestId);
+ const withdrawal = await withdrawalService.getByRequest(userId, requestId);
+ res
+ .set("Cache-Control", "no-store")
+ .json(successResponse(withdrawal, "Withdrawal retrieved"));
+ } catch (error) {
+ next(error);
+ }
+ },
+ async submit(req: AuthRequest, res: Response, next: NextFunction) {
+ try {
+ const userId = tutorId(req);
+ const input = withdrawalSchema.parse(req.body);
+ const result = await withdrawalService.submit(userId, input);
+ res
+ .set("Cache-Control", "no-store")
+ .status(result.replayed ? 200 : 201)
+ .json(successResponse(result.data, "Mock withdrawal completed"));
+ } catch (error) {
+ next(error);
+ }
+ },
+
+ async get(req: AuthRequest, res: Response, next: NextFunction) {
+ try {
+ const userId = tutorId(req);
+ const id = z.string().uuid("Withdrawal ID must be a UUID").parse(req.params.id);
+ const withdrawal = await withdrawalService.get(userId, id);
+ res
+ .set("Cache-Control", "no-store")
+ .json(successResponse(withdrawal, "Withdrawal retrieved"));
+ } catch (error) {
+ next(error);
+ }
+ },
+};
diff --git a/apps/api/src/repository/user.repository.ts b/apps/api/src/repository/user.repository.ts
index 9b5a7c6..bcbb228 100644
--- a/apps/api/src/repository/user.repository.ts
+++ b/apps/api/src/repository/user.repository.ts
@@ -49,6 +49,22 @@ export type ExistingProfileUpdate =
});
export const userRepository = {
+ async findForPasswordVerification(
+ userId: string
+ ): Promise | null> {
+ return prisma.user.findUnique({
+ where: { id: userId },
+ select: { role: true, passwordHash: true },
+ });
+ },
+
+ async findRoleById(userId: string): Promise | null> {
+ return prisma.user.findUnique({
+ where: { id: userId },
+ select: { role: true },
+ });
+ },
+
async updateProfile(userId: string, data: Partial): Promise {
return prisma.user.update({
where: { id: userId },
diff --git a/apps/api/src/repository/withdrawal.repository.ts b/apps/api/src/repository/withdrawal.repository.ts
new file mode 100644
index 0000000..f92579f
--- /dev/null
+++ b/apps/api/src/repository/withdrawal.repository.ts
@@ -0,0 +1,93 @@
+import { Prisma } from "@prisma/client";
+import { prisma } from "../config/prisma.js";
+import {
+ readWithdrawalMetadata,
+ serializeWithdrawalMetadata,
+ withdrawalTransactionId,
+ type WithdrawalMetadata,
+} from "../common/utils/withdrawal-metadata.js";
+
+type WithdrawalInput = {
+ userId: string;
+ requestId: string;
+ requestFingerprint: string;
+ passwordHash: string;
+ amount: Prisma.Decimal;
+ bankCode: WithdrawalMetadata["destination"]["bankCode"];
+ accountNumberMasked: string;
+ accountHolderName: string;
+};
+
+export const withdrawalRepository = {
+ findOwned(userId: string, id: string) {
+ return prisma.transaction.findFirst({ where: { id, userId, type: "withdrawal" } });
+ },
+
+ submit(input: WithdrawalInput) {
+ return prisma.$transaction(
+ async (tx) => {
+ // Serialize requests for this wallet before checking idempotency or balance.
+ // Parameterized SQL; the lock also coordinates with other wallet updates.
+ const users = await tx.$queryRaw<{ role: string; passwordHash: string }[]>`
+ SELECT "role", "passwordHash" FROM "users" WHERE "id" = ${input.userId} FOR UPDATE
+ `;
+ const user = users[0];
+ if (!user) return { kind: "missing-user" } as const;
+ if (user.role !== "tutor") return { kind: "forbidden" } as const;
+ if (user.passwordHash !== input.passwordHash) return { kind: "password-changed" } as const;
+
+ const id = withdrawalTransactionId(input.userId, input.requestId);
+ const existing = await tx.transaction.findUnique({ where: { id } });
+ if (existing) {
+ const metadata = readWithdrawalMetadata(existing);
+ if (
+ existing.userId !== input.userId ||
+ !metadata ||
+ metadata.requestId !== input.requestId ||
+ metadata.requestFingerprint !== input.requestFingerprint ||
+ !existing.amount.equals(input.amount)
+ ) {
+ return { kind: "conflict" } as const;
+ }
+ return { kind: "success", withdrawal: existing, replayed: true } as const;
+ }
+
+ const updated = await tx.user.updateMany({
+ where: { id: input.userId, role: "tutor", walletBalance: { gte: input.amount } },
+ data: { walletBalance: { decrement: input.amount } },
+ });
+ if (updated.count !== 1) return { kind: "insufficient-balance" } as const;
+
+ const wallet = await tx.user.findUniqueOrThrow({
+ where: { id: input.userId },
+ select: { walletBalance: true },
+ });
+ const withdrawal = await tx.transaction.create({
+ data: {
+ id,
+ userId: input.userId,
+ type: "withdrawal",
+ amount: input.amount,
+ relatedSessionId: null,
+ note: serializeWithdrawalMetadata({
+ kind: "withdrawal",
+ version: 1,
+ requestId: input.requestId,
+ requestFingerprint: input.requestFingerprint,
+ currency: "THB",
+ status: "completed",
+ balanceAfter: wallet.walletBalance.toFixed(2),
+ destination: {
+ bankCode: input.bankCode,
+ accountNumberMasked: input.accountNumberMasked,
+ accountHolderName: input.accountHolderName,
+ },
+ }),
+ },
+ });
+ return { kind: "success", withdrawal, replayed: false } as const;
+ },
+ { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }
+ );
+ },
+};
diff --git a/apps/api/src/router/withdrawal.router.ts b/apps/api/src/router/withdrawal.router.ts
new file mode 100644
index 0000000..2732d01
--- /dev/null
+++ b/apps/api/src/router/withdrawal.router.ts
@@ -0,0 +1,185 @@
+import { Router } from "express";
+import { authMiddleware } from "../common/middleware/auth.middleware.js";
+import { withdrawalController } from "../controller/withdrawal.controller.js";
+
+export const withdrawalRouter = Router();
+withdrawalRouter.use(authMiddleware);
+
+/**
+ * @openapi
+ * components:
+ * schemas:
+ * Withdrawal:
+ * type: object
+ * required: [id, amount, currency, status, destination, requestedAt, completedAt, balanceAfter]
+ * properties:
+ * id: { type: string, format: uuid, description: Transaction ID of this withdrawal. }
+ * amount: { type: string, example: "300.00" }
+ * currency: { type: string, enum: [THB] }
+ * status: { type: string, enum: [completed] }
+ * destination:
+ * type: object
+ * properties:
+ * bankCode: { type: string, example: KBANK }
+ * accountNumberMasked: { type: string, example: "******6789" }
+ * accountHolderName: { type: string, example: Tutor Name }
+ * requestedAt: { type: string, format: date-time }
+ * completedAt: { type: string, format: date-time }
+ * balanceAfter:
+ * type: string
+ * example: "700.00"
+ * description: Balance immediately after this withdrawal, not the current balance.
+ * WithdrawalResponse:
+ * type: object
+ * properties:
+ * success: { type: boolean, example: true }
+ * message: { type: string }
+ * data:
+ * $ref: '#/components/schemas/Withdrawal'
+ * WithdrawalError:
+ * type: object
+ * properties:
+ * success: { type: boolean, example: false }
+ * message: { type: string }
+ * responses:
+ * WithdrawalErrorResponse:
+ * description: Request rejected; no balance change.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalError'
+ * /wallet/withdrawals:
+ * post:
+ * summary: Immediately complete a mock tutor withdrawal (no real transfer)
+ * description: >-
+ * THB only, no fees. Bank details are entered each time and only format-validated;
+ * bank ownership is not verified. Password confirms the signed-in tutor.
+ * Reuse requestId with identical normalized details to retry safely. Successful
+ * retries return the original withdrawal without another deduction. Changing
+ * the amount or destination with the same requestId returns 409. Withdrawals use
+ * the existing Transaction model with validated versioned metadata in note.
+ * Incompatible existing retry records also return 409 without deduction.
+ * tags: [Wallet]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * additionalProperties: false
+ * required: [requestId, amount, password, bankCode, accountNumber, accountHolderName]
+ * properties:
+ * requestId: { type: string, format: uuid }
+ * amount:
+ * type: number
+ * minimum: 0.01
+ * maximum: 9999999999.99
+ * multipleOf: 0.01
+ * example: 300
+ * password: { type: string, format: password, minLength: 1, writeOnly: true }
+ * bankCode:
+ * type: string
+ * enum: [BBL, KBANK, KTB, SCB, BAY, TTB, GSB, BAAC, GHB, KKP, TISCO, CIMBT, UOB]
+ * accountNumber:
+ * type: string
+ * maxLength: 64
+ * example: "0123456789"
+ * description: 6-20 digits after removing spaces and hyphens; generic mock validation only.
+ * accountHolderName: { type: string, minLength: 1, maxLength: 100 }
+ * responses:
+ * 201:
+ * description: Mock payout completed and wallet debited atomically.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalResponse'
+ * 200:
+ * description: Identical request replayed; original result returned.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalResponse'
+ * 400:
+ * description: Invalid input or insufficient available balance.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalError'
+ * 401:
+ * $ref: '#/components/responses/WithdrawalErrorResponse'
+ * 403:
+ * description: Tutors only, incorrect confirmation password, or password changed during request.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalError'
+ * 409:
+ * description: Request ID already used with different or incompatible withdrawal details.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalError'
+ * 500:
+ * $ref: '#/components/responses/WithdrawalErrorResponse'
+ * /wallet/withdrawals/{id}:
+ * get:
+ * summary: Retrieve an owned withdrawal with a masked destination
+ * tags: [Wallet]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: string, format: uuid }
+ * responses:
+ * 200:
+ * description: Withdrawal details (Cache-Control no-store).
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalResponse'
+ * 400:
+ * $ref: '#/components/responses/WithdrawalErrorResponse'
+ * 401:
+ * $ref: '#/components/responses/WithdrawalErrorResponse'
+ * 403:
+ * $ref: '#/components/responses/WithdrawalErrorResponse'
+ * 404:
+ * description: Withdrawal does not exist or belongs to another tutor.
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalError'
+ */
+withdrawalRouter.post("/", withdrawalController.submit);
+/**
+ * @openapi
+ * /wallet/withdrawals/by-request/{requestId}:
+ * get:
+ * summary: Reconcile an owned mock withdrawal using its original request ID
+ * description: A 404 means no completed record is visible yet, not that an in-flight POST cannot complete. Retry only with the same request ID.
+ * tags: [Wallet]
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: requestId
+ * required: true
+ * schema: { type: string, format: uuid }
+ * responses:
+ * 200:
+ * description: Completed withdrawal with masked destination
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawalResponse'
+ * 400: { description: Invalid UUID }
+ * 401: { description: Authentication required }
+ * 403: { description: Tutors only }
+ * 404: { description: No valid owned withdrawal is currently visible }
+ */
+withdrawalRouter.get("/by-request/:requestId", withdrawalController.getByRequest);
+withdrawalRouter.get("/:id", withdrawalController.get);
diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts
index b370c12..e4b7b74 100644
--- a/apps/api/src/routes.ts
+++ b/apps/api/src/routes.ts
@@ -3,10 +3,12 @@ import { authRouter } from "./router/auth.router.js";
import { subjectRouter } from "./router/subject.router.js";
import { userRouter } from "./router/user.router.js";
import { walletRouter } from "./router/wallet.router.js";
+import { withdrawalRouter } from "./router/withdrawal.router.js";
export const apiRoutes = Router();
apiRoutes.use("/auth", authRouter);
apiRoutes.use("/subjects", subjectRouter);
apiRoutes.use("/users", userRouter);
+apiRoutes.use("/wallet/withdrawals", withdrawalRouter);
apiRoutes.use("/wallet", walletRouter);
diff --git a/apps/api/src/service/withdrawal.service.ts b/apps/api/src/service/withdrawal.service.ts
new file mode 100644
index 0000000..c588fe1
--- /dev/null
+++ b/apps/api/src/service/withdrawal.service.ts
@@ -0,0 +1,113 @@
+import { createHmac } from "node:crypto";
+import { Prisma, type Transaction } from "@prisma/client";
+import bcrypt from "bcryptjs";
+import {
+ BadRequestError,
+ ConflictError,
+ ForbiddenError,
+ NotFoundError,
+ UnauthorizedError,
+} from "../common/errors/app-error.js";
+import { env } from "../config/env.js";
+import { withdrawalRepository } from "../repository/withdrawal.repository.js";
+import { userRepository } from "../repository/user.repository.js";
+import {
+ readWithdrawalMetadata,
+ withdrawalTransactionId,
+ type WithdrawalMetadata,
+} from "../common/utils/withdrawal-metadata.js";
+
+type SubmitWithdrawal = {
+ requestId: string;
+ amount: number;
+ password: string;
+ bankCode: WithdrawalMetadata["destination"]["bankCode"];
+ accountNumber: string;
+ accountHolderName: string;
+};
+
+function present(withdrawal: Transaction) {
+ const metadata = readWithdrawalMetadata(withdrawal);
+ if (!metadata) throw new NotFoundError("Withdrawal not found");
+ return {
+ id: withdrawal.id,
+ amount: withdrawal.amount.toFixed(2),
+ currency: metadata.currency,
+ status: metadata.status,
+ destination: metadata.destination,
+ requestedAt: withdrawal.createdAt,
+ completedAt: withdrawal.createdAt,
+ balanceAfter: metadata.balanceAfter,
+ };
+}
+
+function requireTutor(user: T | null): T {
+ if (!user) throw new UnauthorizedError("User no longer exists");
+ if (user.role !== "tutor") throw new ForbiddenError("Only tutors can withdraw balance");
+ return user;
+}
+
+export const withdrawalService = {
+ async getByRequest(userId: string, requestId: string) {
+ return withdrawalService.get(userId, withdrawalTransactionId(userId, requestId));
+ },
+ async submit(userId: string, input: SubmitWithdrawal) {
+ const user = requireTutor(await userRepository.findForPasswordVerification(userId));
+ if (!(await bcrypt.compare(input.password, user.passwordHash))) {
+ throw new ForbiddenError("Incorrect confirmation password");
+ }
+
+ const amount = new Prisma.Decimal(input.amount);
+ // Domain-separated HMAC avoids storing the full account number or a guessable plain hash.
+ // The secret must remain stable for retries (see withdrawal API documentation).
+ const requestFingerprint = createHmac("sha256", env.JWT_SECRET)
+ .update(
+ JSON.stringify([
+ "withdrawal-v1",
+ userId,
+ amount.toFixed(2),
+ "THB",
+ input.bankCode,
+ input.accountNumber,
+ input.accountHolderName,
+ ])
+ )
+ .digest("hex");
+
+ const result = await withdrawalRepository.submit({
+ userId,
+ requestId: input.requestId,
+ requestFingerprint,
+ passwordHash: user.passwordHash,
+ amount,
+ bankCode: input.bankCode,
+ accountNumberMasked:
+ "*".repeat(input.accountNumber.length - 4) + input.accountNumber.slice(-4),
+ accountHolderName: input.accountHolderName,
+ });
+
+ switch (result.kind) {
+ case "missing-user":
+ throw new UnauthorizedError("User no longer exists");
+ case "forbidden":
+ throw new ForbiddenError("Only tutors can withdraw balance");
+ case "password-changed":
+ throw new ForbiddenError("Password changed; please confirm again");
+ case "conflict":
+ throw new ConflictError(
+ "Request ID was already used for different or incompatible withdrawal details"
+ );
+ case "insufficient-balance":
+ throw new BadRequestError("Insufficient available balance");
+ case "success":
+ return { data: present(result.withdrawal), replayed: result.replayed };
+ }
+ },
+
+ async get(userId: string, id: string) {
+ requireTutor(await userRepository.findRoleById(userId));
+ const withdrawal = await withdrawalRepository.findOwned(userId, id);
+ if (!withdrawal) throw new NotFoundError("Withdrawal not found");
+ return present(withdrawal);
+ },
+};
diff --git a/apps/web/src/app/balance/withdraw/page.tsx b/apps/web/src/app/balance/withdraw/page.tsx
new file mode 100644
index 0000000..2cff5a5
--- /dev/null
+++ b/apps/web/src/app/balance/withdraw/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from "next";
+import { WithdrawalForm } from "@/components/wallet/withdrawal-form";
+
+export const metadata: Metadata = { title: "Withdraw balance | Tutorist" };
+
+export default function WithdrawalPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx
index 6fdf90a..b2d47ed 100644
--- a/apps/web/src/app/login/page.tsx
+++ b/apps/web/src/app/login/page.tsx
@@ -6,10 +6,16 @@ export const metadata: Metadata = {
description: "Log in to your Tutorist account.",
};
-export default function LoginPage() {
+export default async function LoginPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ next?: string | string[] }>;
+}) {
+ const { next } = await searchParams;
+ const redirectTo = next === "/balance/withdraw" ? next : "/";
return (
-
+
);
}
diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx
index 301dc82..1a3ec0d 100644
--- a/apps/web/src/components/Navbar.tsx
+++ b/apps/web/src/components/Navbar.tsx
@@ -51,8 +51,8 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
const user = getToken() ? getUser() : null;
setSession(user);
setBalance(undefined);
- if (user?.role === "student") {
- apiClient.get("/wallet").then(
+ if (user?.role === "student" || user?.role === "tutor") {
+ apiClient.get(user.role === "tutor" ? "/users/me" : "/wallet").then(
(wallet) => {
if (active && version === accountVersion) {
setBalance(Number(wallet.walletBalance).toFixed(2));
@@ -79,6 +79,8 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
const displayPhoto = profileUrl ?? session?.profileUrl ?? undefined;
const displayBalance = userMoney ?? balance;
const isStudent = session?.role === "student";
+ const isTutor = session?.role === "tutor";
+ const currencySymbol = isTutor ? "฿" : "$";
useEffect(() => {
if (!isSidebarOpen) return;
@@ -128,7 +130,7 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
-
+
{NAV_LINKS.map(({ label, href }) => {
const isActive = pathname === href;
return (
@@ -170,12 +172,20 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
)}
{displayName}
- {displayBalance !== undefined &&
${displayBalance}
}
+ {displayBalance !== undefined && (
+
+ {currencySymbol}
+ {displayBalance}
+
+ )}
- {isStudent && (
-
- Top up balance
+ {(isStudent || isTutor) && (
+
+ {isTutor ? "Withdraw balance" : "Top up balance"}
)}
@@ -276,15 +286,18 @@ export function Navbar({ isLoggedIn, userName, userMoney, profileUrl }: NavbarPr
)}
{displayBalance !== undefined && (
-
${displayBalance}
+
+ {currencySymbol}
+ {displayBalance}
+
)}
- {isStudent && (
+ {(isStudent || isTutor) && (
setIsSidebarOpen(false)}
className="text-xs font-semibold text-primary"
>
- Top up balance
+ {isTutor ? "Withdraw balance" : "Top up balance"}
)}
diff --git a/apps/web/src/components/wallet/withdrawal-form.tsx b/apps/web/src/components/wallet/withdrawal-form.tsx
new file mode 100644
index 0000000..94cfb60
--- /dev/null
+++ b/apps/web/src/components/wallet/withdrawal-form.tsx
@@ -0,0 +1,554 @@
+"use client";
+
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import { ArrowUpRight, Check, Info, Landmark, Wallet } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { PasswordForm } from "@/components/ui/password-form";
+import { apiClient, ApiError } from "@/lib/api-client";
+import { clearSession, getToken } from "@/lib/auth-storage";
+import { formatTHB, withdrawalCents } from "@/lib/withdrawal";
+import type { UserProfile } from "@/types/user";
+import { WITHDRAWAL_BANKS, type Withdrawal } from "@/types/withdrawal";
+import {
+ clearRecovery,
+ isDefinitiveFirstRejection,
+ readRecovery,
+ recoveryKey,
+ saveRecovery,
+ type WithdrawalRecovery,
+} from "@/lib/withdrawal-recovery";
+
+type Field = "amount" | "bank" | "account" | "holder" | "password";
+const LOGIN = "/login?next=/balance/withdraw";
+
+export function WithdrawalForm() {
+ const router = useRouter();
+ const [balance, setBalance] = useState(null);
+ const [amount, setAmount] = useState("");
+ const [bank, setBank] = useState("");
+ const [account, setAccount] = useState("");
+ const [holder, setHolder] = useState("");
+ const [password, setPassword] = useState("");
+ const [errors, setErrors] = useState>>({});
+ const [error, setError] = useState("");
+ const [pending, setPending] = useState(false);
+ const [draft, setDraft] = useState(null);
+ const [userId, setUserId] = useState(null);
+ const [blocked, setBlocked] = useState(false);
+ const [receipt, setReceipt] = useState(null);
+ const [loadVersion, setLoadVersion] = useState(0);
+ const submitting = useRef(false);
+ const formRef = useRef(null);
+ const receiptHeading = useRef(null);
+ const alive = useRef(true);
+ const generation = useRef(0);
+
+ useEffect(() => {
+ alive.current = true;
+ return () => {
+ alive.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ let active = true;
+ let verifiedUserId: string | null = null;
+ const currentGeneration = ++generation.current;
+ const token = getToken();
+ const current = () =>
+ active && generation.current === currentGeneration && getToken() === token;
+ setBalance(null);
+ setUserId(null);
+ setAccount("");
+ setHolder("");
+ setPassword("");
+ setDraft(null);
+ setReceipt(null);
+ setBlocked(false);
+ if (!getToken()) {
+ router.replace(LOGIN);
+ return;
+ }
+ setError("");
+ apiClient.get("/users/me").then(
+ async (user) => {
+ if (!current()) return;
+ if (user.role !== "tutor") {
+ router.replace("/");
+ return;
+ }
+ setUserId(user.id);
+ verifiedUserId = user.id;
+ try {
+ const saved = readRecovery(user.id);
+ if (saved) {
+ setDraft(saved);
+ setAmount(saved.amount.toFixed(2));
+ setBank(saved.bankCode);
+ try {
+ const recovered = await apiClient.get(
+ `/wallet/withdrawals/by-request/${saved.requestId}`
+ );
+ if (!current()) return;
+ setReceipt(recovered);
+ clearRecovery(user.id, saved.requestId);
+ setDraft(null);
+ window.dispatchEvent(new Event("wallet-updated"));
+ router.refresh();
+ const fresh = await apiClient.get("/users/me");
+ if (!current()) return;
+ setBalance(Math.round(fresh.walletBalance * 100));
+ return;
+ } catch (err) {
+ if (!current()) return;
+ if (err instanceof ApiError && err.status === 401) {
+ clearSession();
+ router.replace(LOGIN);
+ return;
+ }
+ if (!(err instanceof ApiError && err.status === 404)) throw err;
+ setError(
+ "No completed withdrawal is visible yet. Re-enter the original bank details and password to retry this same request."
+ );
+ }
+ } else {
+ setAmount("");
+ setBank("");
+ }
+ setBalance(Math.round(user.walletBalance * 100));
+ } catch (err) {
+ if (!current()) return;
+ setBlocked(true);
+ setError(
+ err instanceof Error ? err.message : "Unable to reconcile the previous withdrawal."
+ );
+ }
+ },
+ (err: unknown) => {
+ if (!current()) return;
+ if (err instanceof ApiError && (err.status === 401 || err.status === 404)) {
+ clearSession();
+ router.replace(LOGIN);
+ } else setError("Unable to load your balance. Please try again.");
+ }
+ );
+ function sessionChanged(event: StorageEvent) {
+ if (
+ event.key !== null &&
+ event.key !== "tutorist.auth.token" &&
+ event.key !== "tutorist.auth.user"
+ )
+ return;
+ generation.current++;
+ setBalance(null);
+ setAccount("");
+ setHolder("");
+ setPassword("");
+ setDraft(null);
+ setReceipt(null);
+ setLoadVersion((value) => value + 1);
+ }
+ function recoveryChanged(event: StorageEvent) {
+ if (verifiedUserId && event.key === recoveryKey(verifiedUserId)) {
+ generation.current++;
+ setBalance(null);
+ setLoadVersion((value) => value + 1);
+ }
+ }
+ window.addEventListener("storage", sessionChanged);
+ window.addEventListener("storage", recoveryChanged);
+ return () => {
+ active = false;
+ window.removeEventListener("storage", sessionChanged);
+ window.removeEventListener("storage", recoveryChanged);
+ };
+ }, [router, loadVersion]);
+
+ useEffect(() => {
+ if (receipt) receiptHeading.current?.focus();
+ }, [receipt]);
+
+ useEffect(() => {
+ if (!pending && !draft) return;
+ const warnBeforeLeaving = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ event.returnValue = "";
+ };
+ window.addEventListener("beforeunload", warnBeforeLeaving);
+ return () => window.removeEventListener("beforeunload", warnBeforeLeaving);
+ }, [pending, draft]);
+
+ async function refreshBalance(current: () => boolean, success = false) {
+ try {
+ const user = await apiClient.get("/users/me");
+ if (!current()) return;
+ if (user.role !== "tutor") {
+ router.replace("/");
+ return;
+ }
+ setBalance(Math.round(user.walletBalance * 100));
+ } catch {
+ if (current()) {
+ setBalance(null);
+ setError(
+ success
+ ? "Your receipt is saved, but the current balance could not be refreshed."
+ : "Unable to refresh your balance. Please try again."
+ );
+ }
+ }
+ }
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ if (submitting.current || balance === null || !userId || blocked) return;
+ const cents = withdrawalCents(amount);
+ const normalizedAccount = account.trim().replace(/[ -]/g, "");
+ const normalizedHolder = holder.trim().replace(/\s+/g, " ");
+ const selectedBank = WITHDRAWAL_BANKS.find((item) => item.value === bank);
+ const nextErrors: Partial> = {};
+ // A retry must reach the API even if the original request depleted the wallet.
+ if (!draft) {
+ if (cents === null || cents <= 0)
+ nextErrors.amount = "Enter an amount greater than zero with up to 2 decimal places.";
+ else if (cents > balance) nextErrors.amount = "Insufficient available balance.";
+ }
+ if (!selectedBank) nextErrors.bank = "Select your bank.";
+ if (account.length > 64 || !/^\d{6,20}$/.test(normalizedAccount))
+ nextErrors.account = "Enter an account number with 6–20 digits.";
+ if (!normalizedHolder || holder.trim().length > 100)
+ nextErrors.holder = "Enter an account holder name (up to 100 characters).";
+ if (!password) nextErrors.password = "Enter your password to confirm.";
+ setErrors(nextErrors);
+ setError("");
+ if (Object.keys(nextErrors).length) {
+ requestAnimationFrame(() =>
+ formRef.current?.querySelector('[aria-invalid="true"]')?.focus()
+ );
+ return;
+ }
+ const request: WithdrawalRecovery = draft ?? {
+ version: 1,
+ requestId: crypto.randomUUID(),
+ amount: cents! / 100,
+ bankCode: selectedBank!.value,
+ createdAt: new Date().toISOString(),
+ };
+ const token = getToken();
+ const currentGeneration = generation.current;
+ const current = () =>
+ alive.current && getToken() === token && generation.current === currentGeneration;
+ const wasRetry = draft !== null;
+ submitting.current = true;
+ setPending(true);
+ let sent = false;
+ try {
+ if (!navigator.locks)
+ throw new Error(
+ "This browser cannot safely coordinate withdrawal recovery. Use a supported browser over HTTPS or localhost."
+ );
+ await navigator.locks.request(recoveryKey(userId), { ifAvailable: true }, async (lock) => {
+ if (!lock)
+ throw new Error(
+ "Another tab is submitting a withdrawal. Check its result, then reload this page."
+ );
+ if (!current()) return;
+ const existing = readRecovery(userId);
+ if ((existing?.requestId ?? null) !== (draft?.requestId ?? null))
+ throw new Error(
+ "Withdrawal recovery changed in another tab. Reload to reconcile it before continuing."
+ );
+ saveRecovery(userId, request);
+ setDraft(request);
+ sent = true;
+ const result = await apiClient
+ .post("/wallet/withdrawals", {
+ requestId: request.requestId,
+ amount: request.amount,
+ bankCode: request.bankCode,
+ accountNumber: normalizedAccount,
+ accountHolderName: normalizedHolder,
+ password,
+ })
+ .catch((err: unknown) => {
+ // Clear definitive first-attempt rejections under the same cross-tab lock.
+ // A rejected retry cannot establish the outcome of an earlier uncertain POST.
+ if (
+ current() &&
+ err instanceof ApiError &&
+ isDefinitiveFirstRejection(wasRetry, err.status)
+ ) {
+ clearRecovery(userId, request.requestId);
+ setDraft(null);
+ }
+ throw err;
+ });
+ if (!current()) return;
+ setReceipt(result);
+ clearRecovery(userId, request.requestId);
+ setDraft(null);
+ setAccount("");
+ setHolder("");
+ setBank("");
+ setAmount("");
+ window.dispatchEvent(new Event("wallet-updated"));
+ router.refresh();
+ await refreshBalance(current, true);
+ });
+ } catch (err) {
+ if (!current()) return;
+ if (!sent) {
+ setBlocked(true);
+ setError(err instanceof Error ? err.message : "Unable to save withdrawal recovery.");
+ return;
+ }
+ if (err instanceof ApiError && err.status === 401) {
+ clearSession();
+ router.replace(LOGIN);
+ return;
+ }
+ // Keep a frozen request after an uncertain outcome. Never generate a new ID on retry.
+ if (!(err instanceof ApiError) || err.status >= 500 || err.status === 409) {
+ setDraft(request);
+ setError(
+ err instanceof ApiError && err.status === 409
+ ? "This request conflicts with an existing withdrawal. Do not submit a new withdrawal; contact support to resolve it."
+ : "The result could not be confirmed. Your request ID is saved. Check its status or retry the same withdrawal."
+ );
+ } else {
+ setError(err.message);
+ // An earlier uncertain request stays frozen even when this retry fails validation.
+ if (err.status === 400) await refreshBalance(current);
+ }
+ } finally {
+ submitting.current = false;
+ if (alive.current) setPending(false);
+ if (current()) setPassword("");
+ }
+ }
+
+ if (receipt) {
+ return (
+
+
+
+
+
+ Withdrawal completed
+
+
+ Your mock withdrawal has been recorded. No real bank transfer occurs.
+
+
+ {formatTHB(withdrawalCents(receipt.amount)!)}
+
+ Completed
+
+ {[
+ [
+ "Bank",
+ WITHDRAWAL_BANKS.find((item) => item.value === receipt.destination.bankCode)?.label ??
+ receipt.destination.bankCode,
+ ],
+ ["Account", receipt.destination.accountNumberMasked],
+ ["Account holder", receipt.destination.accountHolderName],
+ ["Requested", new Date(receipt.requestedAt).toLocaleString()],
+ ["Completed", new Date(receipt.completedAt).toLocaleString()],
+ ["Balance after withdrawal", formatTHB(withdrawalCents(receipt.balanceAfter)!)],
+ ["Reference", receipt.id],
+ ].map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+ }
+
+ if (balance === null || blocked)
+ return (
+
+
{error || "Loading your account and balance…"}
+ {error && (
+
+ )}
+
+ );
+ const cents = withdrawalCents(amount) ?? 0;
+ const locked = pending || draft !== null;
+ return (
+
+
Tutor wallet
+
Withdraw your balance
+
Send your earnings to your bank account.
+
+
+
+
+
Transfer to your bank
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/withdrawal-recovery.ts b/apps/web/src/lib/withdrawal-recovery.ts
new file mode 100644
index 0000000..2c8bc8f
--- /dev/null
+++ b/apps/web/src/lib/withdrawal-recovery.ts
@@ -0,0 +1,82 @@
+import { WITHDRAWAL_BANKS, type WithdrawalRequest } from "../types/withdrawal";
+
+type ThaiBankCode = WithdrawalRequest["bankCode"];
+const THAI_BANK_CODES = WITHDRAWAL_BANKS.map(({ value }) => value);
+import { withdrawalCents } from "./withdrawal";
+
+export type WithdrawalRecovery = {
+ version: 1;
+ requestId: string;
+ amount: number;
+ bankCode: ThaiBankCode;
+ createdAt: string;
+};
+
+export const recoveryKey = (userId: string) => `tutorist.withdrawal.pending.v1.${userId}`;
+export function isDefinitiveFirstRejection(wasRetry: boolean, status: number): boolean {
+ return !wasRetry && [400, 401, 403].includes(status);
+}
+const storageError =
+ "Withdrawal recovery storage is unavailable or invalid. Submission is blocked; do not clear it before reconciling your previous withdrawal.";
+
+export function readRecovery(userId: string): WithdrawalRecovery | null {
+ try {
+ const raw = window.localStorage.getItem(recoveryKey(userId));
+ if (raw === null) return null;
+ const data = JSON.parse(raw);
+ if (
+ !data ||
+ typeof data !== "object" ||
+ Object.keys(data).sort().join(",") !== "amount,bankCode,createdAt,requestId,version" ||
+ data.version !== 1 ||
+ typeof data.requestId !== "string" ||
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
+ data.requestId
+ ) ||
+ typeof data.amount !== "number" ||
+ data.amount <= 0 ||
+ withdrawalCents(String(data.amount)) === null ||
+ !THAI_BANK_CODES.includes(data.bankCode) ||
+ typeof data.createdAt !== "string" ||
+ !Number.isFinite(Date.parse(data.createdAt))
+ )
+ throw new Error(storageError);
+ return data as WithdrawalRecovery;
+ } catch {
+ throw new Error(storageError);
+ }
+}
+
+/** Call while holding the user-scoped Web Lock. Never overwrite another pending attempt. */
+export function saveRecovery(userId: string, record: WithdrawalRecovery): void {
+ // TypeScript types do not strip extra fields; persist only recovery metadata.
+ const recovery: WithdrawalRecovery = {
+ version: record.version,
+ requestId: record.requestId,
+ amount: record.amount,
+ bankCode: record.bankCode,
+ createdAt: record.createdAt,
+ };
+ const existing = readRecovery(userId);
+ if (existing && JSON.stringify(existing) !== JSON.stringify(recovery)) {
+ throw new Error("Another withdrawal needs recovery. Reload this page to reconcile it first.");
+ }
+ try {
+ const raw = JSON.stringify(recovery);
+ window.localStorage.setItem(recoveryKey(userId), raw);
+ if (window.localStorage.getItem(recoveryKey(userId)) !== raw) throw new Error(storageError);
+ } catch {
+ throw new Error(storageError);
+ }
+}
+
+export function clearRecovery(userId: string, requestId: string): void {
+ const existing = readRecovery(userId);
+ if (!existing || existing.requestId !== requestId) return;
+ try {
+ window.localStorage.removeItem(recoveryKey(userId));
+ if (window.localStorage.getItem(recoveryKey(userId)) !== null) throw new Error(storageError);
+ } catch {
+ throw new Error(storageError);
+ }
+}
diff --git a/apps/web/src/lib/withdrawal.ts b/apps/web/src/lib/withdrawal.ts
new file mode 100644
index 0000000..b03a26a
--- /dev/null
+++ b/apps/web/src/lib/withdrawal.ts
@@ -0,0 +1,15 @@
+/** Parse decimal input without floating-point arithmetic; values are integer satang. */
+export function withdrawalCents(value: string): number | null {
+ if (!/^\d{1,10}(?:\.\d{1,2})?$/.test(value.trim())) return null;
+ const [whole, fraction = ""] = value.trim().split(".");
+ const cents = Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
+ return cents <= 999999999999 ? cents : null;
+}
+
+export function formatTHB(cents: number): string {
+ return new Intl.NumberFormat("en-TH", {
+ style: "currency",
+ currency: "THB",
+ currencyDisplay: "narrowSymbol",
+ }).format(cents / 100);
+}
diff --git a/apps/web/src/types/withdrawal.ts b/apps/web/src/types/withdrawal.ts
new file mode 100644
index 0000000..0775feb
--- /dev/null
+++ b/apps/web/src/types/withdrawal.ts
@@ -0,0 +1,39 @@
+export const WITHDRAWAL_BANKS = [
+ { value: "KBANK", label: "Kasikornbank" },
+ { value: "SCB", label: "Siam Commercial Bank" },
+ { value: "BBL", label: "Bangkok Bank" },
+ { value: "KTB", label: "Krungthai Bank" },
+ { value: "BAY", label: "Bank of Ayudhya (Krungsri)" },
+ { value: "TTB", label: "TMBThanachart Bank" },
+ { value: "GSB", label: "Government Savings Bank" },
+ { value: "BAAC", label: "Bank for Agriculture (BAAC)" },
+ { value: "GHB", label: "Government Housing Bank" },
+ { value: "KKP", label: "Kiatnakin Phatra Bank" },
+ { value: "TISCO", label: "TISCO Bank" },
+ { value: "CIMBT", label: "CIMB Thai Bank" },
+ { value: "UOB", label: "UOB Thailand" },
+] as const;
+
+export type WithdrawalRequest = {
+ requestId: string;
+ amount: number;
+ bankCode: (typeof WITHDRAWAL_BANKS)[number]["value"];
+ accountNumber: string;
+ accountHolderName: string;
+ password: string;
+};
+
+export type Withdrawal = {
+ id: string;
+ amount: string;
+ currency: "THB";
+ status: "completed";
+ destination: {
+ bankCode: WithdrawalRequest["bankCode"];
+ accountNumberMasked: string;
+ accountHolderName: string;
+ };
+ requestedAt: string;
+ completedAt: string;
+ balanceAfter: string;
+};