Saleor Store
우리 컬렉션을 쇼핑하세요
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기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