尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Multi-vendor marketplace backend built with Node.js, Express, TypeScript, PostgreSQL, Prisma ORM, Stripe, and Paystack.檢視完整 README ↗Multi-vendor marketplace backend built with Node.js, Express, TypeScript, PostgreSQL, Prisma ORM, Stripe, and Paystack.
BUYER, VENDOR, ADMINcharge.refunded webhook handler — reverses vendor wallet credit, restores stock, marks order REFUNDEDTURNSTILE_DISABLED)/api/admin/2fa/*) enforced on refunds, suspensions, and payout mark-paidGET /api/me/data-export, POST /api/me/delete-account/openapi.json, /swagger.json, /api-json, /api/docs.jsonNode.js, Express 4, TypeScript 5, Prisma 6, PostgreSQL, Stripe SDK, bcryptjs, jsonwebtoken.
prisma/
migrations/
schema.prisma
seed.ts
src/
app.ts
server.ts
config/
lib/
middlewares/
modules/
admin/
auth/
buyer-wallet/
cart/
delivery/
health/
notifications/
orders/
payments/
payouts/
products/
promos/
referrals/
reviews/
stripe/
subscriptions/
uploads/
vendors/
routes/
shared/
tests/
Business logic lives in services. Controllers and routes stay thin. All authenticated-user routes use the authenticate middleware; admin routes add requireRole("ADMIN").
Copy .env.example to .env and fill in:
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/marketplace?schema=public"
STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
DEFAULT_CURRENCY=usd
PLATFORM_FEE_BPS=1000
JWT_SECRET=change_me_in_production
JWT_EXPIRES_IN=7d
JWT_SECRET: required. Long random string used to sign access tokens.JWT_EXPIRES_IN: token lifetime (e.g. 7d, 12h). Default 7d.PLATFORM_FEE_BPS: platform fee in basis points, 1000 = 10%. Applied to subtotal only (not delivery).DEFAULT_CURRENCY: currency used for new wallets and as fallback for products.SENTRY_DSN: optional. Sentry project DSN. When set, the centralized error handler captures 5xx and uncaught errors to Sentry. Leave empty to disable monitoring (local dev runs unaffected).REDIS_URL: optional. When set, BullMQ queues (notifications, emails, payouts) are initialized using this Redis instance. Leave empty in local dev to disable queueing — the app still runs without Redis. Use a managed Redis (Upstash, Redis Cloud, Railway, Render…) in production. Format: redis://default:password@host:port or rediss://... for TLS.DATABASE_URL on Vercel (Neon pooled connection)On Vercel serverless, every warm Lambda instance keeps its own PrismaClient. To avoid exhausting Postgres connections you must point DATABASE_URL at the Neon pooled endpoint (the host contains -pooler):
postgresql://USER:PASS@ep-xxx-pooler.<region>.aws.neon.tech/<db>?sslmode=require&pgbouncer=true&connection_limit=1
pgbouncer=true tells Prisma it is talking to PgBouncer (transaction-mode pooling).connection_limit=1 keeps each Lambda using a single backend connection.prisma migrate commands, set a separate DIRECT_URL to the unpooled Neon endpoint (host without -pooler) and add directUrl = env("DIRECT_URL") in prisma/schema.prisma if you run migrations from CI/Vercel.The Prisma client is created once per worker via a globalThis singleton in src/lib/prisma.ts, so no new client is ever instantiated per request.
npm install
cp .env.example .env # or: Copy-Item .env.example .env
npm run prisma:generate
npm run prisma:migrate
npm run prisma:seed
npm run dev
The seed creates one buyer, one vendor owner, one vendor, one wallet, and one product. Default seed password is password123.
All monetary values are stored as integer cents (Int in Prisma). API request/response amounts (priceAmount, amount, baseFeeAmount, feePerKgAmount, subtotalAmount, deliveryAmount, totalAmount) are also integer cents.
All routes are prefixed with /api. Auth is Bearer <JWT> in the Authorization header unless noted.
POST /auth/register — { email, password, name }. Always creates a BUYER. Returns { user, token }.POST /auth/login — { email, password } → { user, token }.GET /auth/me — current user (auth).POST /vendors — create own vendor profile; creates the vendor's wallet and promotes a BUYER → VENDOR (admins keep their role).GET /vendors/me — own profile with wallet.PATCH /vendors/me — partial profile update.POST /vendors/me/payout-methods — { type, label?, details, isDefault? }.GET /vendors/me/payout-methods.GET /products?category=&vendorId=&limit=&cursor= (public) — list active products.GET /products/:id (public) — 404 if missing or inactive.POST /products (VENDOR) — { title, description?, priceAmount, currency?, images?, category?, stock?, weightGrams? }.PATCH /products/:id (VENDOR, owner) — partial update. Supports toggling isActive.DELETE /products/:id (VENDOR, owner) — soft-disable.Cart is per user, supports multiple vendors, and validates stock.
GET /cart — returns (auto-creating if missing).POST /cart/items — { productId, quantity }.PATCH /cart/items/:id — { quantity }.DELETE /cart/items/:id.DELETE /cart — clear all items.POST /delivery/calculate — { cartId, destinationZoneId } → { subtotalAmount, deliveryAmount, totalAmount, totalWeightGrams, currency }. deliveryAmount = baseFeeAmount + ceil(totalWeightGrams / 1000) * feePerKgAmount.POST /payments/create-intent - { cartId, destinationZoneId | deliveryCountry, walletAmount? } returns { checkoutId, orderIds, amount, currency, clientSecret }. Creates one checkout, one order/payment per vendor, reserves stock, optionally debits buyer wallet, and creates a Stripe PaymentIntent for the remaining amount.For Stripe-paid checkouts, the cart is cleared after the webhook marks payment successful. For fully wallet-paid checkouts, the cart is cleared inside the checkout transaction.
POST /stripe/webhook - handles payment_intent.succeeded, payment_intent.payment_failed, payment_intent.canceled, wallet top-up success, and basic dispute logging. It verifies the signature, dedupes on WebhookEvent.stripeEventId, credits vendor pending balances through PAYMENT_PENDING_CREDIT ledger rows, restores stock/wallet deductions on failed or canceled checkouts, clears the buyer cart on success, and emits notifications.POST /payout-requests (VENDOR) — { payoutMethodId, amount, notes? } — validates amount ≤ wallet.availableBalance.GET /payout-requests/me (VENDOR) — list own.GET /admin/users?role=&limit=&cursor=GET /admin/vendors?status=&limit=&cursor=GET /admin/products?isActive=&limit=&cursor=GET /admin/orders?status=&limit=&cursor=GET /admin/payments?status=&limit=&cursor=GET /admin/wallet-transactions?type=&vendorId=&limit=&cursor=PATCH /admin/vendors/:id/approve / PATCH /admin/vendors/:id/rejectPATCH /admin/products/:id/approve / PATCH /admin/products/:id/disablePATCH /admin/orders/:id/complete — PAID → COMPLETED, releases pendingBalance → availableBalance with a PENDING_TO_AVAILABLE ledger row. Protected against duplicate release by the WalletTransaction(vendorId, orderId, paymentId, type) unique constraint.POST /admin/orders/:id/refund — full or partial refund. Branches on payment provider: Stripe (stripe.refunds.create with idempotencyKey) or Paystack (paystack.refundTransaction). Marks order REFUNDED, writes AuditLog. Requires admin 2FA when enabled.GET /admin/payout-requests?status=PATCH /admin/payout-requests/:id/approvePATCH /admin/payout-requests/:id/reject — { reason? }PATCH /admin/payout-requests/:id/mark-paid — APPROVED → PAID, creates PAYOUT_DEBIT, debits availableBalance. Protected against duplicate processing by the WalletTransaction(payoutRequestId, type) unique constraint.GET /notifications?limit=&cursor=&unreadOnly=true — list current user's notifications.PATCH /notifications/:id/read — mark read (idempotent).Notification types: ORDER_PAID, BALANCE_CREDITED, PAYOUT_REQUESTED, PAYOUT_APPROVED, PAYOUT_REJECTED, PAYOUT_PAID.
POST /payments/create-intent with cartId and destinationZoneId:
subtotalAmount = Σ priceInCents * quantitydeliveryFeeAmount = baseFee + ceil(weightKg) * feePerKgtotalAmount = subtotalAmount + deliveryFeeAmountplatformFeeAmount = round(subtotal * PLATFORM_FEE_BPS / 10000)vendorEarningsAmount = subtotalAmount - platformFeeAmount (vendor does not receive delivery fees)totalAmount.pendingBalance += vendorEarningsAmount, cart cleared, ORDER_PAID + BALANCE_CREDITED notifications.COMPLETED → pendingBalance -= amount, availableBalance += amount, PENDING_TO_AVAILABLE ledger row.availableBalance -= amount, PAYOUT_DEBIT ledger row.WebhookEvent.stripeEventId unique — dedupes Stripe retries.WalletTransaction @@unique([vendorId, orderId, paymentId, type]) — one PAYMENT_PENDING_CREDIT and one PENDING_TO_AVAILABLE per order.WalletTransaction @@unique([payoutRequestId, type]) — one PAYOUT_DEBIT per payout request.updateMany with where: { status: ... } to reject concurrent writes.prisma.$transaction.Every endpoint validates its input in a *.validation.ts file and throws AppError on bad input. JSON bodies are parsed by Express except the Stripe webhook which uses express.raw.
npm run prisma:generate
npm run build
Both commands currently exit clean.
The following security measures have been applied across four phases:
updateManyIGNORED + 200payment_intent.canceled handler with wallet restoreBuyerWallet.balance >= 0 CHECK constraintUPDATE WHERE usedCount < maxUses (raw SQL)BETWEEN 1 AND 5 CHECK constraintautomatic_payment_methods: { enabled: true } (replaces payment_method_types)2025-08-27.basileur, usd, gbp)assertSingleVendor, unused creditReferralBonus)AppError.code field for machine-readable error codes