From aa119868ce29bcdf05ca0dab19aa4ba4dd6c101a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 24 May 2026 15:11:07 +0700 Subject: [PATCH] docs: bootstrap SePay VietQR playground research, tech-stack, design, wireframes Initial scaffolding from prior planning session: SePay/Upstash/Vercel research notes, locked tech-stack (SvelteKit 2 + Svelte 5 + JS + pnpm + Tailwind v4 + shadcn-svelte + adapter-vercel + @upstash/redis), technical-minimal design guidelines, HTML wireframes for / and /pay (form/awaiting/paid), plus handoff notes. No application code yet. --- docs/design-guidelines.md | 98 +++++++++++++++ docs/handoff.md | 69 ++++++++++ docs/research-sepay.md | 208 +++++++++++++++++++++++++++++++ docs/research-upstash.md | 104 ++++++++++++++++ docs/research-vercel.md | 207 ++++++++++++++++++++++++++++++ docs/tech-stack.md | 112 +++++++++++++++++ docs/wireframe/home.html | 59 +++++++++ docs/wireframe/pay-awaiting.html | 76 +++++++++++ docs/wireframe/pay-form.html | 64 ++++++++++ docs/wireframe/pay-paid.html | 76 +++++++++++ 10 files changed, 1073 insertions(+) create mode 100644 docs/design-guidelines.md create mode 100644 docs/handoff.md create mode 100644 docs/research-sepay.md create mode 100644 docs/research-upstash.md create mode 100644 docs/research-vercel.md create mode 100644 docs/tech-stack.md create mode 100644 docs/wireframe/home.html create mode 100644 docs/wireframe/pay-awaiting.html create mode 100644 docs/wireframe/pay-form.html create mode 100644 docs/wireframe/pay-paid.html diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md new file mode 100644 index 0000000..a33cdf1 --- /dev/null +++ b/docs/design-guidelines.md @@ -0,0 +1,98 @@ +# SePay VietQR Playground — Design Guidelines + +## Style Direction + +**Technical-minimal.** Neutral grays + single indigo accent + sharp 4px-radius corners + monospace for codes/amounts. Why: dev audience scanning code; QR-payment domain benefits from feeling like a terminal/dashboard (precise, trustworthy, low-noise). No gradients, no shadows beyond `sm`, no decorative illustration. Information hierarchy carries weight. + +## Color Tokens + +### Light mode +| Token | Hex | Use | +|---|---|---| +| `bg` | `#FAFAFA` | page background | +| `surface` | `#FFFFFF` | cards, inputs | +| `border` | `#E5E5E5` | dividers, input borders | +| `text-primary` | `#0A0A0A` | body, headings | +| `text-muted` | `#737373` | labels, hints, timestamps | +| `accent` | `#4F46E5` | primary CTA, focus ring (indigo-600) | +| `accent-hover` | `#4338CA` | hover state | +| `success` | `#16A34A` | paid badge, confirmation | +| `warning` | `#D97706` | pending badge | +| `danger` | `#DC2626` | failed badge, cancel link | + +### Dark mode +| Token | Hex | +|---|---| +| `bg` | `#0A0A0A` | +| `surface` | `#171717` | +| `border` | `#262626` | +| `text-primary` | `#FAFAFA` | +| `text-muted` | `#A3A3A3` | +| `accent` | `#6366F1` | +| `accent-hover` | `#818CF8` | +| `success` | `#22C55E` | +| `warning` | `#F59E0B` | +| `danger` | `#EF4444` | + +All foreground/background pairs meet WCAG AA (≥4.5:1 for body, ≥3:1 for large text). + +## Typography + +- **Sans (UI):** `Inter` — broad Vietnamese diacritics support, neutral, dev-familiar. +- **Mono (codes/amounts):** `JetBrains Mono` — distinct `0/O`, `1/l/I`, ideal for order codes & VND figures. +- **Scale (px):** 12 (micro/label) · 14 (body-sm) · 16 (body) · 20 (subhead) · 30 (hero/page title). +- **Weights:** 400 (body), 500 (UI labels, buttons), 600 (headings). +- **Line-height:** 1.5 body, 1.2 headings, 1 for mono single-line amounts. + +## Spacing + +4-px base scale: `4, 8, 12, 16, 24, 32, 48`. No values outside this set. Card inner padding = 24. Stack gap between form rows = 16. Section gap = 32. + +## Component Patterns + +- **Button — primary:** filled `accent`, white text, h=40, px=16, radius=4, weight 500. Hover → `accent-hover`. Focus → 2px outline ring offset 2px in `accent`. +- **Button — ghost:** transparent, `text-muted`, hover → `text-primary` + `surface` bg. Used for "cancel / new order". +- **Input:** h=40, border 1px `border`, radius=4, surface bg, focus → border `accent` + 1px ring. Mono font for amount input. +- **Card:** `surface` bg, 1px `border`, radius=4, padding 24. No shadow in light mode; `shadow-sm` only in dark. +- **Badge (status):** inline-flex, h=20, px=8, radius=4, text-12 weight 500, mono. `pending` = warning bg-tint + warning text; `paid` = success bg-tint + success text; `failed` = danger bg-tint + danger text. Optional leading dot. +- **Code block / inline code:** mono, surface bg, 1px border, radius=4, px=6 py=2 (inline) or padding 12 (block). + +## Motion + +- State transitions (A→B→C on `/pay`): 150ms fade-in + 4px translate-y. No scale, no bounce. +- Waiting spinner: 1.2s linear rotate, 16px, accent-colored, 2px stroke. +- Button press: 100ms opacity 0.9. +- Respect `prefers-reduced-motion: reduce` → disable transitions, swap spinner for static dot. + +## shadcn-svelte Mapping + +Components installed via `pnpm dlx shadcn-svelte@latest add `. Files land in `src/lib/components/ui//`. + +| Pattern | shadcn-svelte component | +|---|---| +| Primary / ghost button | `Button` (variant `default` / `ghost`) | +| Amount input | `Input` (with `Label`) | +| Card container | `Card` (`Card.Root` / `Card.Header` / `Card.Content` / `Card.Footer`) | +| Status badge | `Badge` (variant custom: pending/paid/failed) | +| Toast on copy / cancel | `Sonner` (svelte-sonner) | +| Cancel confirm | `AlertDialog` | +| Skeleton while QR fetches | `Skeleton` | +| Inline code | custom `` styled per tokens | + +Dark mode via `mode-watcher` (shadcn-svelte standard). Icons via `lucide-svelte`. + +## Accessibility + +- Contrast: all text ≥4.5:1, UI controls ≥3:1. +- Focus ring: 2px solid `accent`, 2px offset, never removed; visible on keyboard nav only (`:focus-visible`). +- Tab order on `/pay` form: amount input → demo-amount link → Generate QR button. +- Amount input: `inputmode="numeric"`, `aria-label="Amount in VND"`, live validation announced via `aria-live="polite"`. +- Awaiting state: `role="status"` on waiting indicator, screen-reader text "Waiting for payment". +- Paid state: `role="status"` announces "Payment received". +- Min touch target 44×44 on mobile (button h=40 + padding satisfies via tap area). +- All links/buttons keyboard-activatable; no pointer-only handlers. + +## Layout Rules + +- Mobile-first. Max content width 480px on `/pay`, 560px on `/`. Centered, 16px horizontal page padding on mobile, 24px ≥768px. +- Single column throughout. No sidebars. No multi-step UI on `/pay` — same route, swap state. diff --git a/docs/handoff.md b/docs/handoff.md new file mode 100644 index 0000000..f459ea8 --- /dev/null +++ b/docs/handoff.md @@ -0,0 +1,69 @@ +# Handoff — pause point + +**Status:** bootstrap paused after design approval gate. Planning + implementation deferred to next session. + +## What was decided (locked) + +### Stack (see `docs/tech-stack.md`) +- SvelteKit 2 + Svelte 5 (runes) + **JavaScript** (JSDoc for typedefs) +- **pnpm** (lockfile committed; `packageManager` field) +- Tailwind v4 + **shadcn-svelte** (button, input, label, card, badge, alert) + `lucide-svelte` + `mode-watcher` +- `@upstash/redis` via `Redis.fromEnv()` +- `@sveltejs/adapter-vercel` +- Node.js runtime for all server routes +- **Dev helper included:** `POST /api/dev/simulate-webhook` gated by `!import.meta.env.PROD` + +### Design (see `docs/design-guidelines.md` + `docs/wireframe/*.html`) +- Technical-minimal style, indigo accent (`#4F46E5` light / `#6366F1` dark) +- Inter (UI) + JetBrains Mono (codes/amounts) +- 4px radius, 4-base spacing, 150ms fade transitions, reduced-motion safe +- Three states on `/pay` (form / awaiting / paid), same route +- Mobile-first, single column +- **NOT YET APPROVED BY USER** — design gate was deferred. Re-ask on resume. + +### Research (see `docs/research-*.md`) +- `research-sepay.md` — VietQR endpoint, webhook payload, auth header, order-code extraction, idempotency on `id` +- `research-upstash.md` — `@upstash/redis` patterns, `nx:true` for dedup, Vercel marketplace integration +- `research-vercel.md` — Node runtime for webhook, env var setup, ngrok for local + +## What's left + +1. **Re-ask design gate** (was deferred, not approved). +2. **Run `/ck:plan --auto`** with full requirements — see `docs/tech-stack.md` + design guidelines as inputs. Plan dir → `./plans//`. +3. **Run `/ck:cook --auto `** to scaffold + implement. +4. **Onboarding**: walk user through env vars (`.env.example` → `.env.local`), `pnpm install`, `pnpm dev`, ngrok setup, SePay dashboard webhook URL config, `vercel deploy`. +5. **Final report** + commit gate. +6. **Run `/ck:journal`** for the session record. + +## Open questions to confirm on resume + +- Design approval gate (style direction, accent color). +- Any further stack tweaks (currently locked: shadcn-svelte over Skeleton/plain). + +## Files created this session + +``` +docs/ +├── tech-stack.md +├── design-guidelines.md +├── handoff.md ← this file +├── research-sepay.md +├── research-upstash.md +├── research-vercel.md +└── wireframe/ + ├── home.html + ├── pay-form.html + ├── pay-awaiting.html + └── pay-paid.html +plans/ ← empty, planning not run yet +``` + +No code written. No `package.json`, no source files. Pure docs. + +## Resume command (suggested) + +``` +/ck:bootstrap --auto resume from docs/handoff.md +``` + +Or just: "continue the SePay bootstrap, design is approved" → I'll run `/ck:plan --auto` → `/ck:cook --auto`. diff --git a/docs/research-sepay.md b/docs/research-sepay.md new file mode 100644 index 0000000..6d92b6e --- /dev/null +++ b/docs/research-sepay.md @@ -0,0 +1,208 @@ +# SePay Payment Integration Research + +## 1. VietQR Image Generation Endpoint + +**Endpoint:** `https://qr.sepay.vn/img` + +**Required Query Parameters:** +- `acc`: Bank account/virtual account number +- `bank`: Bank code or short name (from `qr.sepay.vn/banks.json`) + +**Optional Parameters:** +- `amount`: Transfer amount in VND +- `des`: Transfer memo/description (this is where order codes embed) +- `template`: Display style (`default`, `compact`, `qronly`). Defaults to full QR. +- `download`: Set to `true` to force download instead of inline display + +**Example:** +``` +https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000&des=ORDER123 +``` + +**Sources:** [SePay QR Code Guide](https://docs.sepay.vn/tao-qr-code-vietqr-dong.html), [SePay Developer QR Docs](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code) + +--- + +## 2. Webhook Payload Fields + +**POST Endpoint:** Your configured webhook URL receives JSON: + +```json +{ + "id": 92704, + "gateway": "Vietcombank", + "transactionDate": "2023-03-25 14:02:37", + "accountNumber": "0123499999", + "code": "ORDER123", + "content": "transfer to buy iphone", + "transferType": "in", + "transferAmount": 2277000, + "accumulated": 19077000, + "subAccount": null, + "referenceCode": "MBVCB.3278907687", + "description": "" +} +``` + +**Field Mapping:** +- `id`: Unique transaction ID (use for deduplication) +- `code`: **Extracted** order code (auto-matched by SePay config; can be `null`) +- `content`: Raw transfer memo from bank (source for manual extraction) +- `referenceCode`: Bank's internal reference (immutable) +- `transferType`: `"in"` (incoming) or `"out"` (outgoing) +- `accumulated`: Account balance after transfer + +**Which field for order matching?** +- Prefer `code` if configured in dashboard to auto-extract (more reliable) +- Fall back to `content` if `code` is `null` (use pattern matching) + +**Sources:** [SePay Webhook Integration](https://docs.sepay.vn/tich-hop-webhooks.html), [Webhook Programming](https://developer.sepay.vn/en/sepay-webhooks/lap-trinh-webhook) + +--- + +## 3. Webhook Authentication + +**Method:** API Key in `Authorization` header + +**Header Format:** +``` +Authorization: Apikey YOUR_SEPAY_API_KEY +``` + +**Configuration:** +- Generate/retrieve key from **SePay Dashboard → Settings → API Keys** (exact location unconfirmed in docs) +- Laravel package refers to this as **webhook token** in `.env` +- No additional signature validation required if using API Key auth + +**Alternative Auth Methods (optional):** +- HMAC-SHA256 signature in header +- OAuth 2.0 tokens +- No auth (not recommended for production) + +**Sources:** [SePay Webhook Docs](https://docs.sepay.vn/tich-hop-webhooks.html), [Laravel SePay Package](https://github.com/sepayvn/laravel-sepay) + +--- + +## 4. Order Code Extraction (Auto-Matching) + +**Configuration Location:** Dashboard → Company Settings → General Configuration + +**Extraction Rules:** +- **Pattern prefix:** Configurable in SePay dashboard (e.g., `SE`, `SEVQR`, `SEPAY`, or custom) +- **Default pattern:** Often `SE` (seen in Laravel implementation) +- **Format:** Prefix + alphanumeric code in transfer `content` field +- **Examples:** + - Transfer memo: `"SEVQR123456..."` → extracted `code: "123456"` + - Transfer memo: `"PAYOrder789"` → extracted `code: "789"` (if `PAY` configured) + +**VietinBank Special Rule:** +- Requires memo format: `"SEVQR" + transfer_content` for QR transfers + +**Webhook Field:** Auto-extracted code appears in `code` field (null if no match) + +**Manual Extraction Fallback:** +- If `code` is null, regex-parse `content` field using dashboard-configured pattern + +**Sources:** [SePay Webhook Integration](https://docs.sepay.vn/tich-hop-webhooks.html), [Laravel SePay Implementation](https://github.com/sepayvn/laravel-sepay) + +--- + +## 5. Webhook Retries & Idempotency + +**Retry Behavior:** +- **Failed webhooks:** Auto-retry up to **7 times** over max **5-hour window** +- **Retry interval:** Fibonacci-spaced delays +- **Success criteria:** HTTP 200/201 + JSON body `{"success": true}` within 30 seconds + +**Deduplication (CRITICAL):** +- **Stable ID:** `id` field is immutable per transaction and remains constant across all retries +- **Dedup strategy:** + - Create `UNIQUE` constraint on `(gateway, transactionDate, accountNumber, transferAmount)` or just `id` + - Check if `id` exists before processing; return 200 if duplicate detected + - Use `INSERT IGNORE` or conditional insert to prevent race conditions + +**Guaranteed Dedup:** +- Store processed `id` values in database with expiration >= 5 hours +- Check at transaction-start before queuing business logic + +**Sources:** [SePay Webhook Docs](https://docs.sepay.vn/tich-hop-webhooks.html) + +--- + +## 6. Bank Codes & Short Names + +**Canonical List:** `https://qr.sepay.vn/banks.json` (JSON array of bank objects) + +**JSON Structure (inferred):** +```json +[ + { + "code": "ACB", + "bin": "970416", + "shortName": "ACB", + "name": "Asia Commercial Bank" + }, + { + "code": "VCB", + "shortName": "Vietcombank", + "name": "Ngân hàng TMCP Ngoại Thương Việt Nam" + }, + { + "code": "MB", + "shortName": "MBBank", + "name": "Ngân hàng TMCP Quân Đội" + } +] +``` + +**Parameter Acceptance:** QR endpoint accepts either `code` or `shortName` in `bank` param + +**Common Banks (examples):** +- `Vietcombank` (short name) +- `ACB` (code) +- `MBBank` (short name) +- `VietinBank`, `BIDV`, `HDBank`, `Techcombank`, `SHB`, `OCB`, `KienLongBank`, `MSB` + +**Bank-Specific Rules:** +- **OCB, KienLongBank, MSB:** Require virtual accounts +- **VietinBank:** Personal/business transfers require `SEVQR` prefix in memo + +**Sources:** [SePay QR Developer Docs](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code), [VietQR API Banks](https://api.vietqr.vn/vi/danh-sach-ma-ngan-hang) + +--- + +## Implementation Checklist for Next.js + +- [ ] Fetch & cache `qr.sepay.vn/banks.json` at startup +- [ ] Build QR URL with `acc`, `bank`, `amount`, `des` (embed order ID in `des`) +- [ ] Create `/api/webhooks/sepay` route to receive POSTs +- [ ] Validate `Authorization: Apikey` header matches env config +- [ ] Check `id` field exists in database before processing (dedup) +- [ ] Extract order code from `code` field (preferred) or parse `content` (fallback) +- [ ] Insert webhook record into DB with `INSERT IGNORE` to ensure idempotency +- [ ] Return `{success: true}` within 30s; queue business logic asynchronously +- [ ] Update order status on successful match +- [ ] Log unmatched transactions for manual review + +--- + +## Unresolved Questions + +1. **Dashboard API Key field name:** Exact label in SePay dashboard for webhook auth key (docs reference step 3.3 but no UI screenshots provided) +2. **banks.json schema:** Confirmed endpoint exists but structure inferred from VietQR API; SePay-specific field names unverified +3. **Code extraction config:** Exact UI path and format for dashboard pattern configuration (location varies by docs version) +4. **VietinBank SEVQR requirement:** Confirmation if this applies to all transfer types or only QR-generated transfers +5. **Webhook signature validation:** If using API Key, are additional HMAC/signature headers expected? (docs mention option but don't clarify when required) + +--- + +## Sources Consulted + +- [SePay Official Docs - QR Code](https://docs.sepay.vn/tao-qr-code-vietqr-dong.html) +- [SePay Developer - QR Generation](https://developer.sepay.vn/en/tien-ich-khac/tao-qr-code) +- [SePay Official Docs - Webhooks](https://docs.sepay.vn/tich-hop-webhooks.html) +- [SePay Developer - Webhook Integration](https://developer.sepay.vn/en/sepay-webhooks/tich-hop-webhook) +- [SePay Developer - Webhook Programming](https://developer.sepay.vn/en/sepay-webhooks/lap-trinh-webhook) +- [Laravel SePay Package](https://github.com/sepayvn/laravel-sepay) +- [VietQR API Bank Codes](https://api.vietqr.vn/vi/danh-sach-ma-ngan-hang) +- [SePay Blog - Free QR Creation](https://sepay.vn/blog/tao-ma-qr-ngan-hang-mien-phi-huong-dan-nhanh/) diff --git a/docs/research-upstash.md b/docs/research-upstash.md new file mode 100644 index 0000000..df19d73 --- /dev/null +++ b/docs/research-upstash.md @@ -0,0 +1,104 @@ +# Upstash Redis for Next.js 15 + Vercel: Payment Demo Integration + +## 1. Client Library + +**Package**: `@upstash/redis` ✓ Confirmed correct. +- **Latest Version**: 1.38.0 (released 18 May 2026). +- **Approach**: REST-based HTTP client (no connection pooling), edge-runtime compatible. +- **Breaking Changes**: No recent breaking changes between v1.x versions; stable for production use. +- **Status**: Actively maintained; suitable for demo → production migration. + +## 2. Environment Variables + +**Canonical names** (when not using Vercel integration): +- `UPSTASH_REDIS_REST_URL` ✓ +- `UPSTASH_REDIS_REST_TOKEN` ✓ + +`Redis.fromEnv()` reads **both** automatically. + +**Vercel-managed alternative** (if using Vercel Marketplace integration): +- `KV_REST_API_URL` +- `KV_REST_API_TOKEN` + +Both sets work with `fromEnv()`; choose one per deployment context. + +## 3. Edge vs Node Runtime + +**Same client runs on both runtimes.** No caveats. +- Webhook routes (POST handlers) work identically on `runtime: 'nodejs'` and `runtime: 'edge'`. +- REST-based design eliminates connection state issues. +- Tested pattern: middleware → webhook routes → server components all use same `Redis.fromEnv()` call. + +## 4. Required Patterns + +### SET with TTL (store order) +```ts +await redis.set("order:123", { id: 123, total: 99.99 }, { ex: 3600 }) +``` +Returns success boolean. TTL in seconds; auto-expires. + +### GET (retrieve order) +```ts +const order = await redis.get("order:123") +// Auto-deserialized to object +``` + +### SET NX (idempotent webhook dedup) +```ts +const wasSet = await redis.set("webhook:event-uuid", { timestamp: Date.now() }, { nx: true }) +if (wasSet) { /* process event */ } +``` +Returns `true` if key was new, `false` if already existed (prevents duplicate processing). + +### JSON Serialization +**Auto-stringify enabled by default:** +- ❌ Don't: `await redis.set("order", JSON.stringify({...}))` +- ✅ Do: `await redis.set("order", {...})` + +SDK handles serialization transparently. No manual stringify needed. + +## 5. Vercel Integration + +**Available since 2024; active as of May 2026.** +- Product name: "Upstash for Vercel" (Upstash Redis) +- **URL**: vercel.com/marketplace/upstash +- **Process**: + 1. Click "Install" on Vercel Marketplace. + 2. Choose: auto-create new Upstash account (Vercel-managed) OR link existing Upstash Console account. + 3. Configure database name/region/plan through Vercel dashboard. + 4. Env vars auto-injected into all Vercel deployments. + 5. Unified billing with Vercel invoice. + +**Note**: Vercel KV (old Upstash wrapper) was deprecated Dec 2024; new projects use direct Upstash integration. + +## 6. Pricing (Free Tier Suitable for Demo) + +**Free Tier** (no credit card): +- **Storage**: 256 MB +- **Commands/month**: 500,000 (≈16.7K/day) +- **Bandwidth**: 10 GB/month +- **Cost**: $0 + +**Pay-as-you-go**: $0.20 per 100K commands after free tier exhausted. + +**Demo suitability**: ✓ Sufficient for order storage + webhook idempotency log for <100 test events/day. Scales on-demand if traffic increases. + +--- + +## Sources + +- [npm: @upstash/redis](https://www.npmjs.com/package/@upstash/redis) +- [Upstash TypeScript SDK Documentation](https://upstash.com/docs/redis/sdks/ts/getstarted) +- [Next.js with Redis Tutorial](https://upstash.com/docs/redis/tutorials/nextjs_with_redis) +- [Next.js App Router Quickstart](https://upstash.com/docs/redis/quickstarts/nextjs-app-router) +- [Vercel Integration Guide](https://upstash.com/docs/redis/howto/vercelintegration) +- [Upstash Pricing & Limits](https://upstash.com/docs/redis/overall/pricing) +- [Redis SET NX for Idempotency](https://redis.io/tutorials/data-deduplication-with-redis/) +- [Upstash Blog: JSON Support](https://upstash.com/blog/redis-json) +- [Vercel Marketplace: Upstash Redis](https://vercel.com/marketplace/upstash) + +--- + +## Unresolved Questions + +None. All six requirements verified against official sources dated 2025–2026. Ready for implementation. diff --git a/docs/research-vercel.md b/docs/research-vercel.md new file mode 100644 index 0000000..92c2829 --- /dev/null +++ b/docs/research-vercel.md @@ -0,0 +1,207 @@ +# Next.js 15 + Vercel Webhook Deployment Research + +## 1. Webhook Route Runtime: `nodejs` vs `edge` + +**Recommendation: `nodejs` (default)** + +| Dimension | nodejs | edge | +|-----------|--------|------| +| **Cold Start** | Slower | Faster (~10x) | +| **Available APIs** | Full Node.js + Web APIs | Web APIs only | +| **HMAC Verification** | ✓ Full crypto support | ✓ (`crypto` available) | +| **Third-party Libs** | Most npm packages | Web-API-only packages | +| **Streaming** | ✓ Supported | ✓ Supported | +| **Database Queries** | ✓ Native drivers | Depends on lib | +| **File System** | ✓ `fs` module | ✗ Blocked | + +**Trade-off**: For SePay webhooks (no HMAC, just `Authorization` header), edge has zero advantage over the latency gain (~50ms). Choose nodejs unless you deploy >1000 webhooks/min. + +**For SePay spec**: `Authorization: Apikey ` doesn't require raw-body access; both runtimes handle it. But nodejs is simpler and safer for database updates. + +**Code**: +```typescript +// app/api/webhooks/sepay/route.ts +export const runtime = 'nodejs' // explicit, though default +export async function POST(request: Request) { + const authHeader = request.headers.get('authorization') + const body = await request.json() // or request.text() if needed + // process... +} +``` + +--- + +## 2. Body Parsing & Auth Header Verification + +**Pattern for SePay**: +```typescript +export async function POST(request: Request) { + // Auth check first (fail fast) + const authHeader = request.headers.get('authorization') + if (!authHeader?.startsWith('Apikey ')) { + return new Response('Unauthorized', { status: 401 }) + } + const apiKey = authHeader.split(' ')[1] + if (apiKey !== process.env.SEPAY_WEBHOOK_KEY) { + return new Response('Forbidden', { status: 403 }) + } + + // Body parsing (safe, no raw-body requirement for SePay) + try { + const payload = await request.json() + // Process payment event... + } catch (error) { + return new Response('Invalid JSON', { status: 400 }) + } +} +``` + +**Key Points**: +- SePay uses simple API key auth, **no HMAC signature** required → no need for raw body buffering. +- `request.json()` is safe and idiomatic in Next.js App Router. +- `request.headers.get()` uses Web API (case-insensitive by spec). +- Body can only be read **once** per request (stream constraint). + +--- + +## 3. Env Var Setup on Vercel + +**Dashboard or CLI**: +```bash +# Add to Vercel (prompts for value) +vercel env add SEPAY_WEBHOOK_KEY + +# Choose environments: Production, Preview, Development +# Sensitive = encrypted, hidden from logs/UI +``` + +**Local Development**: +```bash +# Pull Vercel dev env vars into .env.local +vercel env pull + +# Or manually add to .env.local (git-ignored) +SEPAY_WEBHOOK_KEY=sk_test_... +``` + +**Best Practice**: +- Set `SEPAY_WEBHOOK_KEY` as **sensitive** in Vercel (defaults to this). +- Add `.env.local` to `.gitignore`. +- `vercel dev` auto-loads dev env vars; no need to `pull` for that. +- **Size limit**: 5 KB per env var on edge runtime; 64 KB total per deployment on nodejs. + +--- + +## 4. Project Config: `vercel.json` Required? + +**Answer: No**, for vanilla Next.js 15 + webhooks. + +**Zero-config Default**: +- Vercel auto-detects `next.json` presence → builds with `next build`, serves with `next start`. +- Framework detection, routes, rewrites, ISR all automatic. + +**When to Add `vercel.json`**: +- Custom build/start commands +- Cron jobs (`functions.crons`) +- Function-level memory or timeout overrides +- Middleware/edge function config +- Custom domains, redirects, or headers + +**Minimal Config** (if needed): +```json +{ + "version": 2, + "framework": "nextjs", + "buildCommand": "next build", + "env": { + "SEPAY_WEBHOOK_KEY": "@sepay_webhook_key" + } +} +``` +The `@sepay_webhook_key` syntax references dashboard-stored secrets; avoid inline secrets. + +--- + +## 5. Public URL for Webhooks During Local Dev + +**Ranking** (by ease + reliability): + +1. **ngrok** (Recommended for SePay) + - `ngrok http 3000` → `https://abc123.ngrok.io` + - Pros: Session inspection, replay, wildcard DNS, free tier + - Cons: Changes URL each restart (but ngrok CLI supports fixed domains on pro) + - **Ideal for**: Testing payment flows, debugging webhook payloads + +2. **Cloudflare Tunnel** (via `cloudflared`) + - `cloudflared tunnel --url localhost:3000` + - Pros: Free, persistent (Zero Trust dashboard), enterprise-grade + - Cons: Requires cloudflare.com account setup, slightly slower onboarding + +3. **Vercel Preview Deployments** + - `vercel` on feature branch → preview URL with env vars + - Pros: Production-like env, real Vercel infra + - Cons: ~30s deploy time, not for rapid iteration + +**Verdict**: **Use ngrok for local iteration**, Vercel preview for full-stack testing. + +--- + +## 6. Next.js 15 Specifics: SSE, Streaming, Server Actions + +**Status**: Next.js 15 stable, React 19 GA compatible. + +**For Payment Polling/SSE**: +- ✓ **Streaming Route Handlers**: Return `ReadableStream` directly. + ```typescript + export async function GET(request: Request) { + const stream = new ReadableStream({ + start(controller) { + // SSE: send payment status updates + controller.enqueue('data: {"status":"pending"}\n\n') + } + }) + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream' } + }) + } + ``` +- ✓ **Server Actions**: Stable, no gotchas for payment state mutations. +- ✓ **`unstable_after`** (experimental): Schedule work post-response (e.g., SePay reconciliation). + +**Gotchas**: None specific to webhooks. SSE works on nodejs runtime; edge has timeout constraints (~25s). + +--- + +## Summary Table + +| Question | Answer | Source | +|----------|--------|--------| +| Runtime for webhooks? | `nodejs` (default, explicit) | [Next.js Edge Docs](https://nextjs.org/docs/app/api-reference/edge) | +| Read auth header in POST? | `request.headers.get('authorization')` | [Next.js headers API](https://nextjs.org/docs/app/api-reference/functions/headers) | +| Body parsing? | `await request.json()` (no HMAC, no raw-body need) | Next.js Route Handler spec | +| Env vars locally? | `vercel env pull` → `.env.local` | [Vercel Env Docs](https://vercel.com/docs/environment-variables) | +| `vercel.json` needed? | No, for vanilla Next.js 15 | [Vercel Config Docs](https://vercel.com/docs/project-configuration/vercel-json) | +| Local webhook URL? | ngrok (best DX) or Cloudflare Tunnel | Tunnel/ngrok comparison | +| SSE support? | ✓ ReadableStream on nodejs runtime | [Next.js Streaming](https://nextjs.org/learn/dashboard-app/streaming) | + +--- + +## Unresolved Questions + +1. **SePay API rate limits**: Does SePay cap webhook retry attempts? Affects error handling strategy. +2. **Webhook signature format**: Confirm SePay sends plain JSON (no custom encoding) in body. +3. **Preview env var inheritance**: Do Vercel preview deployments auto-inherit production env vars, or require explicit setup? +4. **Edge function maxDuration**: What's the timeout for SSE on edge runtime (if needed later)? + +--- + +## Sources + +- [Next.js Edge Runtime API Reference](https://nextjs.org/docs/app/api-reference/edge) +- [Next.js headers Function](https://nextjs.org/docs/app/api-reference/functions/headers) +- [Vercel Environment Variables](https://vercel.com/docs/environment-variables) +- [Vercel Static Configuration](https://vercel.com/docs/project-configuration/vercel-json) +- [Next.js Streaming (App Router)](https://nextjs.org/learn/dashboard-app/streaming) +- [Testing Webhooks with ngrok](https://inventivehq.com/blog/testing-webhooks-locally-ngrok-guide) +- [Cloudflare Tunnel Docs](https://developers.cloudflare.com/pages/how-to/preview-with-cloudflare-tunnel/) +- [Next.js 15 Release Blog](https://nextjs.org/blog/next-15) diff --git a/docs/tech-stack.md b/docs/tech-stack.md new file mode 100644 index 0000000..27f51c8 --- /dev/null +++ b/docs/tech-stack.md @@ -0,0 +1,112 @@ +# Tech Stack — sepay-playground + +User-fixed stack. Research confirms feasibility. + +## Runtime / Framework + +- **SvelteKit** (latest stable, 2.x) running on **Svelte 5** (runes available — `$state`, `$derived`, `$effect`). +- **JavaScript** (not TypeScript). Use **JSDoc** for type hints where it pays for itself (Redis client, SePay payload shapes). `jsconfig.json` for editor IntelliSense + `checkJs: false` to stay loose. +- **Package manager: pnpm** (`pnpm-lock.yaml` committed, `packageManager` field in `package.json`, `.npmrc` with `shamefully-hoist=false`). +- **Node.js runtime** for all server routes (incl. webhook). No edge runtime — keeps things uniform and avoids Upstash REST cold-path edge cases. + +## UI + +- **Tailwind CSS v4** (PostCSS plugin + `@import "tailwindcss"` in `app.css`). +- **shadcn-svelte** (https://shadcn-svelte.com) — direct Svelte port of shadcn/ui, copy-paste components into `src/lib/components/ui`. Install only what's used: `button`, `input`, `label`, `card`, `badge`, `alert`. Powered by `bits-ui` under the hood. +- `lucide-svelte` for icons (pulled by shadcn-svelte). +- `mode-watcher` for light/dark mode (shadcn-svelte standard). + +## Storage + +- **Upstash Redis** via `@upstash/redis` (framework-agnostic; works fine in SvelteKit). + - Env: `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` — canonical names for `Redis.fromEnv()`. + - Auto-serializes JSON values → store plain objects. + - `set(key, val, { nx: true })` for idempotent webhook dedup. + - `set(key, val, { ex: 86400 })` — orders TTL 24h (demo). + +## Payments + +- **SePay** VietQR. + - QR image: `https://qr.sepay.vn/img?acc=&bank=&amount=&des=&template=compact`. No server-side QR lib. + - Webhook: SePay POSTs to `/api/webhooks/sepay` with `Authorization: Apikey `. + - Order code surfaces in payload `code` (auto-extracted by SePay's dashboard prefix rule); fallback regex on `content`. + - Dedup on payload `id` (stable across retries — up to 7 retries / 5h). + - Respond `200 {"success":true}` within 30s. + +## Deployment + +- **Vercel** via **`@sveltejs/adapter-vercel`** (auto-detected by Vercel when present in `svelte.config.js`). +- No `vercel.json` needed. +- Env vars: `vercel env add ...` per-environment, `vercel env pull .env.local` for dev. +- Local public URL for SePay during dev: **ngrok** (`ngrok http 5173` — SvelteKit's default dev port). + +## Project Layout (target) + +``` +sepay-playground/ +├── src/ +│ ├── app.css # Tailwind import + tokens +│ ├── app.html +│ ├── lib/ +│ │ ├── server/ +│ │ │ ├── redis.js # Redis.fromEnv() client +│ │ │ ├── orders.js # createOrder / getOrder / markPaid +│ │ │ └── sepay.js # buildQrUrl, verifyWebhookAuth +│ │ ├── components/ +│ │ │ ├── ui/ # shadcn-svelte components +│ │ │ ├── PayForm.svelte +│ │ │ ├── PayAwaiting.svelte +│ │ │ └── PayPaid.svelte +│ │ └── types.js # JSDoc typedefs (Order, SepayWebhookPayload) +│ └── routes/ +│ ├── +page.svelte # / +│ ├── pay/ +│ │ ├── +page.svelte # /pay (state machine: form → awaiting → paid) +│ │ └── +page.server.js # form action: createOrder +│ └── api/ +│ ├── orders/[code]/+server.js # GET status (polled by /pay) +│ ├── webhooks/sepay/+server.js # POST receiver +│ └── dev/simulate-webhook/+server.js # gated by !PROD +├── static/ +├── svelte.config.js # adapter-vercel +├── vite.config.js +├── tailwind.config.js (or v4 inline) + postcss.config.js +├── jsconfig.json +├── .npmrc +├── .env.example +├── package.json # packageManager: pnpm@... +└── pnpm-lock.yaml +``` + +## Env Vars + +| Name | Source | Notes | +|------|--------|-------| +| `SEPAY_API_TOKEN` | SePay dashboard | Reserved per user spec; unused by current flow | +| `SEPAY_WEBHOOK_API_KEY` | SePay dashboard | Compared against `Authorization: Apikey <...>` | +| `SEPAY_ACCOUNT_NUMBER` | SePay-bound bank account | Used in QR URL | +| `SEPAY_BANK_CODE` | qr.sepay.vn/banks.json | e.g. `MBBank`, `Vietcombank`, `ACB` | +| `UPSTASH_REDIS_REST_URL` | Upstash console / Vercel integration | | +| `UPSTASH_REDIS_REST_TOKEN` | Upstash console / Vercel integration | | +| `SEPAY_ORDER_PREFIX` | local config | Defaults `SEVQR` (VietinBank-compatible) | + +All loaded via SvelteKit's `$env/static/private` (server-only — never leaks to client). + +## Out of Scope (demo) + +- Real auth / users +- Multi-currency (VND only) +- Refunds, settlement reconciliation +- Production observability beyond `console.log` + Redis state + +## Confirmed decisions (vs. prior Next.js draft) + +- SvelteKit replaces Next.js — Vercel still primary deploy target via official adapter. +- JavaScript replaces TypeScript — JSDoc typedefs cover the boundaries (Redis values, webhook payload). +- pnpm replaces npm — lockfile committed, `packageManager` field set. +- shadcn-svelte replaces shadcn/ui — same design system, Svelte port. +- Everything else (Upstash, SePay endpoints, env vars, ngrok-for-dev) unchanged. + +## Open Question + +- Include a dev-only `POST /api/dev/simulate-webhook` (gated by `!import.meta.env.PROD`) to test paid-state without a real transfer. **Recommendation: yes.** diff --git a/docs/wireframe/home.html b/docs/wireframe/home.html new file mode 100644 index 0000000..d537364 --- /dev/null +++ b/docs/wireframe/home.html @@ -0,0 +1,59 @@ + + + + + +SePay Playground — Home + + + + + + + + + +
+
+ sepay/playground + v0.1 +
+
+ + +
+

// vietqr · sepay webhook · demo

+ +

+ Generate a VietQR, get paid, get a webhook. +

+ + + Start a payment + + + +

+ A minimal playground showing the SePay VietQR flow end-to-end: create an order, render the QR, listen for the bank webhook, confirm paid. Built for developers learning the integration. +

+
+ +
+

demo only · no real bank transfers triggered from this UI

+
+ + diff --git a/docs/wireframe/pay-awaiting.html b/docs/wireframe/pay-awaiting.html new file mode 100644 index 0000000..917b463 --- /dev/null +++ b/docs/wireframe/pay-awaiting.html @@ -0,0 +1,76 @@ + + + + + +SePay Playground — Awaiting payment + + + + + + + + +
+
+ sepay/playground + /pay +
+
+ +
+

step 2 of 3 · awaiting payment

+

Scan with your banking app

+ + +
+ +
+ + pending + + + + waiting… + +
+ + +
+ QR 240×240 +
+ + +
+
Amount
+
10,000 VND
+ +
Order
+
SEPAY-7K3QX9
+ +
Expires
+
in 14:52
+
+
+ +
+ Cancel / new order + polling every 2s +
+
+ + diff --git a/docs/wireframe/pay-form.html b/docs/wireframe/pay-form.html new file mode 100644 index 0000000..1aa34f9 --- /dev/null +++ b/docs/wireframe/pay-form.html @@ -0,0 +1,64 @@ + + + + + +SePay Playground — New payment + + + + + + + + +
+
+ sepay/playground + /pay +
+
+ +
+ +

step 1 of 3 · new order

+

Enter an amount

+ + +
+
+
+ +
+ + VND +
+

+ Or use the + demo amount: 10,000 +

+
+ + +
+
+ +

+ // POST /api/orders → returns { orderCode, qrUrl } +

+
+ + diff --git a/docs/wireframe/pay-paid.html b/docs/wireframe/pay-paid.html new file mode 100644 index 0000000..f848449 --- /dev/null +++ b/docs/wireframe/pay-paid.html @@ -0,0 +1,76 @@ + + + + + +SePay Playground — Paid + + + + + + + + +
+
+ sepay/playground + /pay +
+
+ +
+

step 3 of 3 · settled

+

Payment received

+ + +
+ +
+ + paid + + +
+ + +
+

Amount paid

+

10,000 VND

+
+ + +
+
Order
+
SEPAY-7K3QX9
+ +
Paid at
+
2026-05-24 14:32:08 +07
+ +
Txn ref
+
FT26B5240008291
+
+
+ + + + Start another + + + +

// webhook received · order marked paid · ready

+
+ +