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

+
+
+
+ 1. Withdrawal amount + setAmount(e.target.value)} + error={!!errors.amount} + errorMessage={errors.amount} + /> + + {balance === 0 && ( +

You have no available balance to withdraw.

+ )} +
+
+ 2. Bank account details +

Enter mock bank account details.

+ setAccount(e.target.value)} + error={!!errors.account} + errorMessage={errors.account} + /> + setHolder(e.target.value)} + error={!!errors.holder} + errorMessage={errors.holder} + /> +
+
+

3. Confirm it’s you

+ setPassword(e.target.value)} + disabled={pending} + autoComplete="current-password" + className="max-w-none" + error={!!errors.password} + errorMessage={errors.password} + /> +

+ Use your Tutorist password to confirm this withdrawal. +

+
+ {error && ( +

+ {error} +

+ )} + {draft && ( +

+ Amount and bank are locked to the saved request. Re-enter the original account + details and your password to retry. A missing result does not mean the original + request cannot still complete. +

+ )} + {draft && ( + + )} + +
+
+
+
+ ); +} 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; +};