From b5913a5c76310d909c54e2f082b55136550ca619 Mon Sep 17 00:00:00 2001 From: NoneTP Date: Tue, 8 Sep 2026 00:51:15 +0700 Subject: [PATCH 1/4] withdrawal-backend --- .../migration.sql | 1 + apps/api/prisma/schema/transaction.prisma | 1 + .../src/common/utils/withdrawal-metadata.ts | 88 ++++++++++ .../src/controller/withdrawal.controller.ts | 75 +++++++++ .../src/repository/withdrawal.repository.ts | 100 +++++++++++ apps/api/src/router/withdrawal.router.ts | 158 ++++++++++++++++++ apps/api/src/routes.ts | 2 + apps/api/src/service/withdrawal.service.ts | 109 ++++++++++++ 8 files changed, 534 insertions(+) create mode 100644 apps/api/prisma/schema/migrations/20260908000000_add_withdrawal_transaction_type/migration.sql create mode 100644 apps/api/src/common/utils/withdrawal-metadata.ts create mode 100644 apps/api/src/controller/withdrawal.controller.ts create mode 100644 apps/api/src/repository/withdrawal.repository.ts create mode 100644 apps/api/src/router/withdrawal.router.ts create mode 100644 apps/api/src/service/withdrawal.service.ts 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..ac3cdca --- /dev/null +++ b/apps/api/src/controller/withdrawal.controller.ts @@ -0,0 +1,75 @@ +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 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/withdrawal.repository.ts b/apps/api/src/repository/withdrawal.repository.ts new file mode 100644 index 0000000..3bff4f5 --- /dev/null +++ b/apps/api/src/repository/withdrawal.repository.ts @@ -0,0 +1,100 @@ +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 = { + findUser(userId: string) { + return prisma.user.findUnique({ + where: { id: userId }, + select: { role: true, passwordHash: true }, + }); + }, + + 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..9c5cc4d --- /dev/null +++ b/apps/api/src/router/withdrawal.router.ts @@ -0,0 +1,158 @@ +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); +withdrawalRouter.get("/:id", withdrawalController.get); diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index d6faeba..37ca034 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -2,9 +2,11 @@ import { Router } from "express"; import { authRouter } from "./router/auth.router.js"; import { subjectRouter } from "./router/subject.router.js"; import { userRouter } from "./router/user.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); diff --git a/apps/api/src/service/withdrawal.service.ts b/apps/api/src/service/withdrawal.service.ts new file mode 100644 index 0000000..9eefb34 --- /dev/null +++ b/apps/api/src/service/withdrawal.service.ts @@ -0,0 +1,109 @@ +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 { + readWithdrawalMetadata, + 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, + }; +} + +async function requireTutor(userId: string) { + const user = await withdrawalRepository.findUser(userId); + 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 submit(userId: string, input: SubmitWithdrawal) { + const user = await requireTutor(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) { + await requireTutor(userId); + const withdrawal = await withdrawalRepository.findOwned(userId, id); + if (!withdrawal) throw new NotFoundError("Withdrawal not found"); + return present(withdrawal); + }, +}; From c49f631968700bf92054425accb36427ce6feb83 Mon Sep 17 00:00:00 2001 From: NoneTP Date: Sun, 13 Sep 2026 21:08:55 +0700 Subject: [PATCH 2/4] Fix withdrawal recovery and share bank definitions --- .github/workflows/ci.yml | 8 +- apps/api/package.json | 7 +- .../src/common/utils/withdrawal-metadata.ts | 18 +- apps/api/src/config/swagger.ts | 4 + .../src/controller/withdrawal.controller.ts | 12 + apps/api/src/router/withdrawal.router.ts | 29 ++- apps/api/src/service/withdrawal.service.ts | 4 + apps/web/package.json | 10 +- .../src/components/wallet/withdrawal-form.tsx | 236 ++++++++++++++---- apps/web/src/lib/withdrawal-recovery.ts | 71 ++++++ apps/web/src/types/withdrawal.ts | 19 +- eslint.config.js | 9 + package.json | 3 +- packages/shared/package.json | 19 ++ packages/shared/src/index.ts | 23 ++ packages/shared/tsconfig.json | 13 + yarn.lock | 10 + 17 files changed, 408 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/lib/withdrawal-recovery.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 083be8b..1030be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,13 @@ jobs: - name: Install dependencies run: yarn install --immutable - - name: Prepare api .env from example + - name: Build shared package + run: yarn workspace @tutorist/shared build + + - name: Verify web types + run: yarn workspace web check + + - name: Prepare API environment run: cp apps/api/.env.example apps/api/.env - name: Generate Prisma client diff --git a/apps/api/package.json b/apps/api/package.json index 7e43776..e5ddf01 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,10 +5,10 @@ "main": "src/server.ts", "license": "MIT", "scripts": { - "dev": "tsx watch src/server.ts", - "build": "tsc", + "dev": "yarn workspace @tutorist/shared build && tsx watch src/server.ts", + "build": "yarn workspace @tutorist/shared build && tsc", "start": "node dist/src/server.js", - "check": "tsc --noEmit", + "check": "yarn workspace @tutorist/shared build && tsc --noEmit", "db.create": "docker-compose up -d db", "db.start": "docker-compose start db", "db.stop": "docker-compose stop db", @@ -21,6 +21,7 @@ }, "dependencies": { "@prisma/client": "^6.1.0", + "@tutorist/shared": "workspace:*", "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/apps/api/src/common/utils/withdrawal-metadata.ts b/apps/api/src/common/utils/withdrawal-metadata.ts index 2cfb37f..537337c 100644 --- a/apps/api/src/common/utils/withdrawal-metadata.ts +++ b/apps/api/src/common/utils/withdrawal-metadata.ts @@ -2,22 +2,8 @@ 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; +import { THAI_BANK_CODES } from "@tutorist/shared"; +export { THAI_BANK_CODES } from "@tutorist/shared"; const metadataSchema = z .object({ diff --git a/apps/api/src/config/swagger.ts b/apps/api/src/config/swagger.ts index 5b7c967..83ef73f 100644 --- a/apps/api/src/config/swagger.ts +++ b/apps/api/src/config/swagger.ts @@ -1,8 +1,12 @@ import swaggerJsdoc from "swagger-jsdoc"; +import { THAI_BANK_CODES } from "@tutorist/shared"; export const swaggerSpec = swaggerJsdoc({ definition: { openapi: "3.0.0", + components: { + schemas: { ThaiBankCode: { type: "string", enum: THAI_BANK_CODES } }, + }, info: { title: "SE Project API", version: "1.0.0", diff --git a/apps/api/src/controller/withdrawal.controller.ts b/apps/api/src/controller/withdrawal.controller.ts index ac3cdca..1d3da20 100644 --- a/apps/api/src/controller/withdrawal.controller.ts +++ b/apps/api/src/controller/withdrawal.controller.ts @@ -46,6 +46,18 @@ function tutorId(req: AuthRequest) { } 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); diff --git a/apps/api/src/router/withdrawal.router.ts b/apps/api/src/router/withdrawal.router.ts index 9c5cc4d..2ef4ca2 100644 --- a/apps/api/src/router/withdrawal.router.ts +++ b/apps/api/src/router/withdrawal.router.ts @@ -81,7 +81,7 @@ withdrawalRouter.use(authMiddleware); * 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] + * $ref: '#/components/schemas/ThaiBankCode' * accountNumber: * type: string * maxLength: 64 @@ -155,4 +155,31 @@ withdrawalRouter.use(authMiddleware); * $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/service/withdrawal.service.ts b/apps/api/src/service/withdrawal.service.ts index 288baca..c588fe1 100644 --- a/apps/api/src/service/withdrawal.service.ts +++ b/apps/api/src/service/withdrawal.service.ts @@ -13,6 +13,7 @@ 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"; @@ -47,6 +48,9 @@ function requireTutor(user: T | null): T { } 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))) { diff --git a/apps/web/package.json b/apps/web/package.json index f0e9174..cc264d1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,14 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "yarn workspace @tutorist/shared build && next dev", + "build": "yarn workspace @tutorist/shared build && next build", + "check": "yarn workspace @tutorist/shared build && tsc --noEmit", "start": "next start", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "storybook": "yarn workspace @tutorist/shared build && storybook dev -p 6006", + "build-storybook": "yarn workspace @tutorist/shared build && storybook build" }, "dependencies": { "@radix-ui/react-dropdown-menu": "^2.1.24", + "@tutorist/shared": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", diff --git a/apps/web/src/components/wallet/withdrawal-form.tsx b/apps/web/src/components/wallet/withdrawal-form.tsx index 7829c24..94cfb60 100644 --- a/apps/web/src/components/wallet/withdrawal-form.tsx +++ b/apps/web/src/components/wallet/withdrawal-form.tsx @@ -11,9 +11,16 @@ 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, type WithdrawalRequest } from "@/types/withdrawal"; +import { WITHDRAWAL_BANKS, type Withdrawal } from "@/types/withdrawal"; +import { + clearRecovery, + isDefinitiveFirstRejection, + readRecovery, + recoveryKey, + saveRecovery, + type WithdrawalRecovery, +} from "@/lib/withdrawal-recovery"; -type Draft = Omit; type Field = "amount" | "bank" | "account" | "holder" | "password"; const LOGIN = "/login?next=/balance/withdraw"; @@ -28,13 +35,16 @@ export function WithdrawalForm() { const [errors, setErrors] = useState>>({}); const [error, setError] = useState(""); const [pending, setPending] = useState(false); - const [draft, setDraft] = useState(null); + 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; @@ -45,29 +55,94 @@ export function WithdrawalForm() { 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( - (user) => { - if (!active) return; + async (user) => { + if (!current()) return; if (user.role !== "tutor") { router.replace("/"); return; } - setBalance(Math.round(user.walletBalance * 100)); + 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 (!active) return; + 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() { + 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(""); @@ -76,10 +151,19 @@ export function WithdrawalForm() { 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]); @@ -97,26 +181,30 @@ export function WithdrawalForm() { return () => window.removeEventListener("beforeunload", warnBeforeLeaving); }, [pending, draft]); - async function refreshBalance() { + async function refreshBalance(current: () => boolean, success = false) { try { const user = await apiClient.get("/users/me"); - if (!alive.current) return; + if (!current()) return; if (user.role !== "tutor") { router.replace("/"); return; } setBalance(Math.round(user.walletBalance * 100)); } catch { - if (alive.current) { + if (current()) { setBalance(null); - setError("Your receipt is saved, but the current balance could not be refreshed."); + 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) return; + 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, " "); @@ -127,12 +215,12 @@ export function WithdrawalForm() { 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 (!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(""); @@ -142,33 +230,81 @@ export function WithdrawalForm() { ); return; } - const request: Draft = draft ?? { + const request: WithdrawalRecovery = draft ?? { + version: 1, requestId: crypto.randomUUID(), amount: cents! / 100, bankCode: selectedBank!.value, - accountNumber: normalizedAccount, - accountHolderName: normalizedHolder, + 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 { - const result = await apiClient.post("/wallet/withdrawals", { - ...request, - password, + 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); }); - if (!alive.current || getToken() !== token) return; - setReceipt(result); - setDraft(null); - setAccount(""); - setHolder(""); - setBank(""); - setAmount(""); - window.dispatchEvent(new Event("wallet-updated")); - router.refresh(); - await refreshBalance(); } catch (err) { - if (!alive.current || getToken() !== token) return; + 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); @@ -180,19 +316,17 @@ export function WithdrawalForm() { 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. Retry the same withdrawal below; do not reload or start another withdrawal." + : "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(); + if (err.status === 400) await refreshBalance(current); } } finally { submitting.current = false; - if (alive.current) { - setPending(false); - setPassword(""); - } + if (alive.current) setPending(false); + if (current()) setPassword(""); } } @@ -239,6 +373,7 @@ export function WithdrawalForm() { )}