diff --git a/.gitignore b/.gitignore index 86ce498..0c5622a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /out/ /.open-next/ /.wrangler/ +.wrangler-secrets.tmp.json # production /build diff --git a/README.md b/README.md index 57e5ba8..7ccf8c6 100644 --- a/README.md +++ b/README.md @@ -66,26 +66,94 @@ Locales: | `npm run lint` | ESLint | | `npm test` | Unit tests (Vitest) | | `npm run typecheck` | TypeScript (`tsc --noEmit`) | +| `npm run db:migrate` | Apply SQL migrations to Neon (uses `.env.local` → `.env.prod` → `.env`) | +| `npm run db:migrate:prod` | Apply migrations to **production** Neon (`MIGRATE_ENV=prod`, reads `.env.prod` only) | +| `npm run db:reset:prod-payments` | Dry-run cleanup of sandbox rows in prod Neon; pass `--confirm` to apply | | `npm run preview` | Build OpenNext worker and preview in `workerd` via Wrangler | | `npm run build:worker` | Build the Cloudflare Worker bundle only | | `npm run deploy` | Build + deploy to Cloudflare Workers | -| `npm run deploy:live` | Build + deploy current branch/commit as the **live** production Worker (tagged) | +| `npm run deploy:live` | Full production deploy — see [Deploy to production](#deploy-to-production) | +| `npm run cf:secrets` | Upload Worker secrets from `.env.prod` without redeploying | | `npm run cf:whoami` | Show Cloudflare auth account | | `npm run cf:status` | Show the live Workers deployment status | -### Deploy from your machine +### Environment files + +| File | Purpose | +| --- | --- | +| `.env.local` | Local development (gitignored). Copy from `.env.example`. | +| `.env.prod` | Production secrets and config (gitignored). Used by deploy and prod migrations. | +| `.env.example` | Template with placeholder keys — safe to commit. | + +Never commit `.env.local` or `.env.prod`. + +### Database migrations + +Migrations live in `migrations/*.sql` and are applied in filename order. + +```bash +# Local / dev Neon +npm run db:migrate + +# Production Neon (uses DATABASE_URL from .env.prod) +npm run db:migrate:prod +``` + +The app also runs `ensureSchema()` on first DB access, which applies any pending migration files automatically. + +### Deploy to production You do not need GitHub Actions or the Cloudflare dashboard to ship: ```bash -# one-time: log in if needed +# One-time: log in to Cloudflare npx wrangler login -# ship the current branch as production +# Create .env.prod with production values (see .env.example) +# Then ship: npm run deploy:live ``` -`deploy:live` tags the Worker version with the current git branch and short SHA (and marks `-dirty` if you have uncommitted changes), then rolls it out to 100% production traffic. +#### What `npm run deploy:live` does + +Runs `scripts/deploy-live.sh`, which: + +1. **Checks auth** — exits if `wrangler whoami` fails. +2. **Build env** — copies `.env.prod` → `.env.production.local` so `NEXT_PUBLIC_*` (e.g. site URL) is baked into the Next.js build, not localhost from `.env.local`. +3. **Sync secrets** — calls `scripts/sync-cloudflare-env.mjs` to upload every key in `.env.prod` to the `stackwise-technologies` Worker via `wrangler secret bulk`. +4. **Build** — runs `npm run build:worker` (OpenNext + Next.js production build). +5. **Deploy** — publishes to Cloudflare Workers at 100% traffic, tagged with git branch + short SHA (e.g. `main@abc1234`). + +#### Sync secrets only (no redeploy) + +After editing `.env.prod`, push new secrets without rebuilding: + +```bash +npm run cf:secrets +# equivalent to: node scripts/sync-cloudflare-env.mjs .env.prod +``` + +Requires `wrangler` login. Uploads all non-comment `KEY=value` lines from the file as encrypted Worker secrets. + +#### Script reference (`scripts/`) + +| File | Invoked by | Description | +| --- | --- | --- | +| `scripts/deploy-live.sh` | `npm run deploy:live` | End-to-end production deploy: env, secrets, build, publish. | +| `scripts/sync-cloudflare-env.mjs` | `npm run cf:secrets`, `deploy-live.sh` | Parses an env file and runs `wrangler secret bulk` for Worker `stackwise-technologies`. Optional arg: path to env file (default `.env.prod`). | +| `scripts/migrate.mjs` | `npm run db:migrate`, `npm run db:migrate:prod` | Loads env files, connects to Neon, runs all `migrations/*.sql` in order. Set `MIGRATE_ENV=prod` to force `.env.prod`. | +| `scripts/db-reset-prod-payments.mjs` | `npm run db:reset:prod-payments` | Removes test/sandbox payment links from production Neon and marks remaining drafts as live (`kpay_is_test = false`). Dry-run by default; `--confirm` to apply. | + +### Admin & payments (local) + +- Admin dashboard: [http://localhost:3000/admin](http://localhost:3000/admin) +- Payment admin uses Neon Postgres, KPay, and SMTP — configure in `.env.local`. +- For local payment E2E, set `NEXT_PUBLIC_SITE_URL=http://localhost:3000` in `.env.local`. +- See [docs/PAYMENTS.md](docs/PAYMENTS.md) for payment flow, receipts, and legal notices. + +### Payments legal & compliance + +Customer payment pages (`/pay/*`) include a legal notice linking to [Payment processing & data](/pay/legal). See [docs/PAYMENTS.md](docs/PAYMENTS.md) for how payments, receipts, and personal data are handled. --- diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000..bb993f8 --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,61 @@ +# Payments — legal, receipts, and customer flow + +## Customer payment pages + +Public routes under `/pay/[slug]`: + +| Route | Purpose | +| --- | --- | +| `/pay/[slug]` | Invoice + pay (Card / Mobile Money via KPay) | +| `/pay/[slug]/return` | KPay return handler | +| `/pay/[slug]/receipt` | **Paid only** — printable receipt (A4) | +| `/pay/legal` | Payment processing & data notice | + +Every pay page includes a **PaymentLegalNotice** footer linking to `/pay/legal`. + +## Payment processor + +- **KPay** handles checkout (card + Mobile Money). +- Stackwise stores invoice metadata and payment status in Neon Postgres. +- Card numbers and mobile-money PINs are never stored by Stackwise. + +## Audit logging + +Customer interactions on pay pages log IP address and user agent to `payment_audit_logs`: + +| Event | Trigger | +| --- | --- | +| `PAY_PAGE_VIEW` | Invoice page load | +| `PAYMENT_INIT` | Pay button / KPay redirect | +| `RETURN_CALLBACK` | KPay return URL | +| `RECEIPT_VIEW` | Receipt page (paid only) | + +Logs are visible at `/admin/audit-logs` (latest 100 events) and on each payment-link detail page. Apply migration `0003_payment_audit_logs.sql` (or `npm run db:migrate`). + +## Receipt access + +Receipts are gated by payment status: + +- `canAccessReceipt(link)` returns true only when `status === "PAID"`. +- Unpaid links redirect from `/pay/[slug]/receipt` to the pay page. +- Admin “View receipt” is shown only for paid links. + +## Receipt email + +When payment completes (webhook or return URL sync), `finalizePaidPayment`: + +1. Marks the link `PAID` +2. Sends receipt email to the customer via `sendPaymentReceiptEmails` +3. Notifies admins listed in `ADMIN_NOTIFY_EMAILS` + +The email includes a **receipt URL** (`/pay/[slug]/receipt`) for viewing and printing. + +## Admin dashboard + +- Live links: `kpay_is_test = false` only (production DB filter) +- Tables support **search** (invoice, customer, email) and **status filter** +- **10 rows per page** pagination + +## Environment variables + +See `.env.example` for `KPAY_*`, `DATABASE_URL`, `SMTP_*`, and `ADMIN_EMAILS`. diff --git a/migrations/0001_neon_init.sql b/migrations/0001_neon_init.sql new file mode 100644 index 0000000..f811ed3 --- /dev/null +++ b/migrations/0001_neon_init.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS admin_otps ( + id UUID PRIMARY KEY, + subject_hash VARCHAR(64) NOT NULL, + otp VARCHAR(6) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_admin_otps_subject ON admin_otps(subject_hash); + +CREATE TABLE IF NOT EXISTS otp_verify_attempts ( + subject_hash VARCHAR(64) PRIMARY KEY, + failed_count INT NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS otp_send_throttle ( + subject_hash VARCHAR(64) PRIMARY KEY, + last_attempt_at TIMESTAMPTZ NULL, + last_sent_at TIMESTAMPTZ NULL +); + +CREATE TABLE IF NOT EXISTS payment_links ( + id UUID PRIMARY KEY, + slug VARCHAR(32) UNIQUE NOT NULL, + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'XAF', + amount_usd NUMERIC(12, 2) NOT NULL, + amount_local NUMERIC(14, 2) NOT NULL, + exchange_rate NUMERIC(16, 8) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + line_items JSONB NOT NULL, + notes TEXT, + kpay_payment_id TEXT, + kpay_reference TEXT, + gateway_url TEXT, + invoice_number VARCHAR(32) UNIQUE NOT NULL, + sent_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + receipt_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payment_links_slug ON payment_links(slug); +CREATE INDEX IF NOT EXISTS idx_payment_links_status ON payment_links(status); +CREATE INDEX IF NOT EXISTS idx_payment_links_created ON payment_links(created_at DESC); diff --git a/migrations/0002_kpay_is_test.sql b/migrations/0002_kpay_is_test.sql new file mode 100644 index 0000000..5b3846a --- /dev/null +++ b/migrations/0002_kpay_is_test.sql @@ -0,0 +1 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS kpay_is_test BOOLEAN; diff --git a/migrations/0003_payment_audit_logs.sql b/migrations/0003_payment_audit_logs.sql new file mode 100644 index 0000000..44368ae --- /dev/null +++ b/migrations/0003_payment_audit_logs.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS payment_audit_logs ( + id UUID PRIMARY KEY, + payment_link_id UUID NOT NULL REFERENCES payment_links(id) ON DELETE CASCADE, + event VARCHAR(32) NOT NULL, + ip_address VARCHAR(45), + user_agent TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_payment_audit_link ON payment_audit_logs(payment_link_id, created_at DESC); diff --git a/migrations/0004_allowed_payment_methods.sql b/migrations/0004_allowed_payment_methods.sql new file mode 100644 index 0000000..27072b7 --- /dev/null +++ b/migrations/0004_allowed_payment_methods.sql @@ -0,0 +1 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS allowed_payment_methods VARCHAR(20) NOT NULL DEFAULT 'BOTH'; diff --git a/migrations/0005_manual_payment_fields.sql b/migrations/0005_manual_payment_fields.sql new file mode 100644 index 0000000..709c616 --- /dev/null +++ b/migrations/0005_manual_payment_fields.sql @@ -0,0 +1,7 @@ +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_source VARCHAR(20); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_method VARCHAR(32); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_usd NUMERIC(12, 2); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS amount_received_local NUMERIC(14, 2); +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS collected_at TIMESTAMPTZ; +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_reference TEXT; +ALTER TABLE payment_links ADD COLUMN IF NOT EXISTS payment_notes TEXT; diff --git a/package-lock.json b/package-lock.json index 131e365..7ef5bd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "stackwise-technologies-limited", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stackwise-technologies-limited", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { + "@neondatabase/serverless": "^1.0.2", "next": "16.2.6", + "nodemailer": "^7.0.6", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -16,6 +18,7 @@ "@opennextjs/cloudflare": "^1.20.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^7.0.1", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -2970,6 +2973,15 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@next/env": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", @@ -4640,6 +4652,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-80vKwiIsVSyFA1rRovH59jNPLBOuc6dRZIHEu40gXTkBkZnQv8vog1xSGEb9j5q/tdMAs5ivvDR2pLTU0hGHXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -10137,6 +10159,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/package.json b/package.json index b642739..02093ec 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.2.0", "private": true, "scripts": { + "db:migrate": "node scripts/migrate.mjs", "dev": "next dev", "build": "next build", "start": "next start", @@ -13,15 +14,21 @@ "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "deploy:live": "bash scripts/deploy-live.sh", + "cf:secrets": "node scripts/sync-cloudflare-env.mjs .env.prod", + "db:migrate:prod": "MIGRATE_ENV=prod node scripts/migrate.mjs", + "db:reset:prod-payments": "MIGRATE_ENV=prod node scripts/db-reset-prod-payments.mjs", "cf:whoami": "wrangler whoami", "cf:status": "wrangler deployments status --name stackwise-technologies" }, "dependencies": { + "@neondatabase/serverless": "^1.0.2", "next": "16.2.6", + "nodemailer": "^7.0.6", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { + "@types/nodemailer": "^7.0.1", "@opennextjs/cloudflare": "^1.20.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", diff --git a/scripts/db-reset-prod-payments.mjs b/scripts/db-reset-prod-payments.mjs new file mode 100644 index 0000000..40a61d9 --- /dev/null +++ b/scripts/db-reset-prod-payments.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Remove sandbox/test payment links from production Neon and mark remaining drafts as live. + * + * Usage: + * node scripts/db-reset-prod-payments.mjs # dry run (default) + * node scripts/db-reset-prod-payments.mjs --confirm # apply changes + * + * Requires DATABASE_URL in .env.prod (MIGRATE_ENV=prod). + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { neon } from "@neondatabase/serverless"; + +function loadEnvFile(filename, { override = false } = {}) { + const envPath = path.join(process.cwd(), filename); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (override || !process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(".env.prod", { override: true }); + +const confirm = process.argv.includes("--confirm"); +const url = process.env.DATABASE_URL; + +if (!url) { + console.error("DATABASE_URL is not set (.env.prod)"); + process.exit(1); +} + +const sql = neon(url); + +const flaggedTest = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test = true + ORDER BY created_at DESC +`; + +const legacySandbox = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test IS NULL AND kpay_payment_id IS NOT NULL + ORDER BY created_at DESC +`; + +const draftsToMarkLive = await sql` + SELECT id, invoice_number, status, kpay_is_test + FROM payment_links + WHERE kpay_is_test IS NULL AND kpay_payment_id IS NULL + ORDER BY created_at DESC +`; + +const toDelete = [...flaggedTest, ...legacySandbox]; + +console.log("Production payment link cleanup"); +console.log("================================"); +console.log(`Mode: ${confirm ? "APPLY" : "DRY RUN (pass --confirm to apply)"}`); +console.log(""); +console.log(`Delete (test / legacy sandbox): ${toDelete.length}`); +for (const row of toDelete) { + console.log(` - ${row.invoice_number} (${row.status}, kpay_is_test=${row.kpay_is_test})`); +} +console.log(""); +console.log(`Mark as live (drafts with no KPay id): ${draftsToMarkLive.length}`); +for (const row of draftsToMarkLive) { + console.log(` - ${row.invoice_number} (${row.status}) → kpay_is_test = false`); +} + +if (!confirm) { + console.log(""); + console.log("No changes made. Re-run with --confirm to apply."); + process.exit(0); +} + +if (toDelete.length > 0) { + const ids = toDelete.map((row) => row.id); + await sql` + DELETE FROM payment_links + WHERE id = ANY(${ids}::uuid[]) + `; + console.log(`Deleted ${toDelete.length} payment link(s).`); +} + +if (draftsToMarkLive.length > 0) { + const draftIds = draftsToMarkLive.map((row) => row.id); + await sql` + UPDATE payment_links + SET kpay_is_test = false, updated_at = NOW() + WHERE id = ANY(${draftIds}::uuid[]) + `; + console.log(`Marked ${draftsToMarkLive.length} draft link(s) as live.`); +} + +const remaining = await sql` + SELECT + COUNT(*) FILTER (WHERE kpay_is_test = false) AS live_count, + COUNT(*) FILTER (WHERE kpay_is_test = true) AS test_count, + COUNT(*) FILTER (WHERE kpay_is_test IS NULL) AS unset_count + FROM payment_links +`; + +console.log(""); +console.log("Remaining rows:", remaining[0]); +console.log("Done."); diff --git a/scripts/deploy-live.sh b/scripts/deploy-live.sh index 0d904ef..f28774f 100755 --- a/scripts/deploy-live.sh +++ b/scripts/deploy-live.sh @@ -38,6 +38,32 @@ echo " tag: ${TAG}" echo " message: ${MESSAGE}" echo +ENV_PROD="${ROOT}/.env.prod" +PROD_LOCAL="${ROOT}/.env.production.local" +PROD_LOCAL_BACKUP="" +if [[ -f "$ENV_PROD" ]]; then + echo "==> Applying .env.prod for Next.js production build" + if [[ -f "$PROD_LOCAL" ]]; then + PROD_LOCAL_BACKUP="${PROD_LOCAL}.deploy-backup" + cp "$PROD_LOCAL" "$PROD_LOCAL_BACKUP" + fi + cp "$ENV_PROD" "$PROD_LOCAL" +else + echo "warning: .env.prod not found — NEXT_PUBLIC_* may default to localhost" >&2 +fi + +cleanup_prod_local() { + if [[ -n "$PROD_LOCAL_BACKUP" && -f "$PROD_LOCAL_BACKUP" ]]; then + mv "$PROD_LOCAL_BACKUP" "$PROD_LOCAL" + elif [[ -f "$PROD_LOCAL" && -f "$ENV_PROD" ]]; then + rm -f "$PROD_LOCAL" + fi +} +trap cleanup_prod_local EXIT + +echo "==> Syncing Cloudflare Worker secrets from .env.prod" +node scripts/sync-cloudflare-env.mjs .env.prod + echo "==> Building OpenNext Cloudflare worker" npm run build:worker diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..b777fdf --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Apply SQL migrations in migrations/*.sql (sorted) to Neon Postgres. + * + * Usage: + * node scripts/migrate.mjs # .env.local → .env.prod → .env + * MIGRATE_ENV=prod node scripts/migrate.mjs # .env.prod only + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { neon } from "@neondatabase/serverless"; + +function loadEnvFile(filename, { override = false } = {}) { + const envPath = path.join(process.cwd(), filename); + if (!fs.existsSync(envPath)) return; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (override || !process.env[key]) process.env[key] = value; + } +} + +if (process.env.MIGRATE_ENV === "prod") { + loadEnvFile(".env.prod", { override: true }); +} else { + loadEnvFile(".env.local"); + loadEnvFile(".env.prod"); + loadEnvFile(".env"); + loadEnvFile(".env.example"); +} + +const url = process.env.DATABASE_URL; +if (!url) { + console.error("DATABASE_URL is required"); + process.exit(1); +} + +const sql = neon(url); +const migrationsDir = path.join(process.cwd(), "migrations"); +const files = fs + .readdirSync(migrationsDir) + .filter((name) => name.endsWith(".sql")) + .sort(); + +for (const file of files) { + const migrationPath = path.join(migrationsDir, file); + const migration = fs.readFileSync(migrationPath, "utf8"); + const statements = migration + .split(";") + .map((statement) => statement.trim()) + .filter(Boolean); + + for (const statement of statements) { + await sql.query(statement); + console.log(`[${file}]`, statement.split("\n")[0].slice(0, 80)); + } +} + +console.log("Migration complete:", files.join(", ")); diff --git a/scripts/sync-cloudflare-env.mjs b/scripts/sync-cloudflare-env.mjs new file mode 100644 index 0000000..343fefb --- /dev/null +++ b/scripts/sync-cloudflare-env.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/** + * Upload production env vars from .env.prod to the Cloudflare Worker as secrets. + * Usage: node scripts/sync-cloudflare-env.mjs [.env.prod] + */ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; + +const WORKER_NAME = "stackwise-technologies"; +const envFile = process.argv[2] ?? ".env.prod"; +const envPath = path.join(process.cwd(), envFile); + +if (!fs.existsSync(envPath)) { + console.error(`Missing env file: ${envPath}`); + process.exit(1); +} + +const secrets = {}; +for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key) secrets[key] = value; +} + +if (Object.keys(secrets).length === 0) { + console.error("No variables found in env file"); + process.exit(1); +} + +const tmpPath = path.join(process.cwd(), ".wrangler-secrets.tmp.json"); +fs.writeFileSync(tmpPath, JSON.stringify(secrets, null, 2)); + +console.log(`Uploading ${Object.keys(secrets).length} secrets to Worker "${WORKER_NAME}" from ${envFile}…`); +console.log(`Keys: ${Object.keys(secrets).join(", ")}`); + +const result = spawnSync( + "npx", + ["wrangler", "secret", "bulk", tmpPath, "--name", WORKER_NAME], + { stdio: "inherit", cwd: process.cwd() }, +); + +fs.unlinkSync(tmpPath); + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +console.log("Cloudflare secrets updated."); diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index bc9b61e..56ca763 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { Geist, Geist_Mono } from "next/font/google"; import { notFound } from "next/navigation"; +import { ClientProviders } from "@/components/ClientProviders"; +import { createPageMetadata } from "@/lib/metadata"; import { isLocale, locales, type Locale } from "@/lib/content"; import "../../styles/globals.css"; @@ -15,46 +17,16 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - icons: [ - { - rel: "icon", - type: "image/svg+xml", - url: "/favicon.svg", - }, - ], - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - openGraph: { - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - url: "https://stackwisetechnologies.com", - siteName: "Stackwise Technologies Ltd", - images: [ - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", - width: 1200, - height: 630, - }, - { - url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", - width: 1200, - height: 1200, - }, - ], - locale: "en_US", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Stackwise Technologies LTD | Your Engineering Partner for Product Development", - description: - "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery.", - images: ["https://stackwisetechnologies.com/image/og-landing-1200x630.png"], - }, +const MARKETING_TITLE = + "Stackwise Technologies LTD | Your Engineering Partner for Product Development"; +const MARKETING_DESCRIPTION = + "Stackwise Technologies Ltd is your trusted engineering partner for product development. We specialize in custom platforms, AI systems, cloud infrastructure, dedicated teams, and the essential work that drives business delivery."; +export const metadata: Metadata = { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }), keywords: [ "Stackwise Technologies", "Engineering Partner", @@ -110,6 +82,24 @@ export const metadata: Metadata = { "Software Engineering Solutions", "Product Development Services", ], + openGraph: { + ...createPageMetadata({ + title: MARKETING_TITLE, + description: MARKETING_DESCRIPTION, + }).openGraph, + images: [ + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x630.png", + width: 1200, + height: 630, + }, + { + url: "https://stackwisetechnologies.com/images/og-landing-1200x1200.png", + width: 1200, + height: 1200, + }, + ], + }, }; export function generateStaticParams() { @@ -133,7 +123,7 @@ export default async function LocaleLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + {children} ); diff --git a/src/app/admin/audit-logs/page.tsx b/src/app/admin/audit-logs/page.tsx new file mode 100644 index 0000000..d18e25d --- /dev/null +++ b/src/app/admin/audit-logs/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { PaymentAuditLogsPanel } from "@/components/admin/PaymentAuditLogsPanel"; +import { getAdminSession } from "@/lib/auth/session"; +import { listRecentPaymentAuditLogs } from "@/lib/db/payment-audit"; + +export const metadata = { + title: "Access logs | Admin", +}; + +export default async function AuditLogsPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const logs = await listRecentPaymentAuditLogs(); + + return ( +
+
+ + ← Back to dashboard + +

Payment access logs

+

+ IP address and browser user agent captured on pay pages for internal + auditing. Loaded {logs.length} most recent events. +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..76fd9a7 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { AdminShell } from "@/components/admin/AdminShell"; +import { ClientProviders } from "@/components/ClientProviders"; +import { createPageMetadata } from "@/lib/metadata"; +import "../../styles/globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: "Admin | Stackwise Technologies", + description: + "Stackwise Technologies payments admin — manage invoices, payment links, withdrawals, and access logs.", + path: "/admin", + noIndex: true, +}); + +export default function AdminLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 0000000..e12e468 --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { OtpInput } from "@/components/admin/OtpInput"; +import Mark from "@/components/Mark"; +import { useToast } from "@/components/ToastProvider"; +import { SITE_NAME } from "@/lib/content"; +import { BTN_GHOST, BTN_PRIMARY } from "@/lib/ui/buttons"; + +export default function AdminLoginPage() { + const router = useRouter(); + const toast = useToast(); + const [step, setStep] = useState<"email" | "otp">("email"); + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [loading, setLoading] = useState(false); + + async function sendOtp(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const res = await fetch("/api/admin/login/otp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(data.error ?? "Unable to send code"); + setStep("otp"); + toast.toast("If your email is authorized, a verification code was sent."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Unable to send code"); + } finally { + setLoading(false); + } + } + + async function verifyOtpWithCode(code: string) { + setLoading(true); + try { + const res = await fetch("/api/admin/login/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, otp: code }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(data.error ?? "Invalid code"); + router.push("/admin"); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Invalid code"); + } finally { + setLoading(false); + } + } + + async function verifyOtp(e: React.FormEvent) { + e.preventDefault(); + await verifyOtpWithCode(otp); + } + + return ( +
+
+
+ +
+

{SITE_NAME}

+

Admin sign in with email code

+
+
+ + {step === "email" ? ( +
+ + +
+ ) : ( +
+

+ Enter the 6-digit code sent to {email} +

+
+

+ Verification code +

+ { + if (!loading) { + void verifyOtpWithCode(code); + } + }} + /> +
+ + +
+ )} +
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..17859d8 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { EnvironmentBadge } from "@/components/admin/EnvironmentBadge"; +import { PaymentLinksList } from "@/components/admin/PaymentLinksList"; +import { getAdminSession } from "@/lib/auth/session"; +import { listPaymentLinks } from "@/lib/db/payment-links"; +import { isKPayTestMode } from "@/lib/kpay/environment"; +import { BTN_PRIMARY } from "@/lib/ui/buttons"; + +export default async function AdminDashboardPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const kpayMode = isKPayTestMode() ? "test" : "production"; + const showTestSection = kpayMode === "test"; + const [liveLinks, testLinks] = showTestSection + ? await Promise.all([ + listPaymentLinks("production"), + listPaymentLinks("test"), + ]) + : [await listPaymentLinks("production"), []]; + + return ( +
+
+
+

Payment links

+

Signed in as {session.email}

+
+ KPay mode: + +
+
+ + New payment link + +
+ + + + {liveLinks.length === 0 && !showTestSection && ( +
+

No payment links yet.

+ + Create your first link + +
+ )} +
+ ); +} diff --git a/src/app/admin/payment-links/[id]/page.tsx b/src/app/admin/payment-links/[id]/page.tsx new file mode 100644 index 0000000..103131d --- /dev/null +++ b/src/app/admin/payment-links/[id]/page.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById } from "@/lib/db/payment-links"; +import { EnvironmentBadge } from "@/components/admin/EnvironmentBadge"; +import { PaymentAuditLog } from "@/components/admin/PaymentAuditLog"; +import { ManualPaymentSummary } from "@/components/admin/ManualPaymentSummary"; +import { RecordManualPaymentForm } from "@/components/admin/RecordManualPaymentForm"; +import { ResendReceiptButton } from "@/components/admin/ResendReceiptButton"; +import { SendLinkButton } from "@/components/admin/SendLinkButton"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { resolvePaymentEnvironment } from "@/lib/kpay/environment"; +import { canAccessReceipt } from "@/lib/payments/receipt-access"; +import { PAYMENT_METHODS_OPTIONS } from "@/lib/payments/payment-methods"; +import { BTN_SECONDARY } from "@/lib/ui/buttons"; + +function siteUrl() { + return process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; +} + +export default async function PaymentLinkDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) notFound(); + + const payUrl = `${siteUrl()}/pay/${link.slug}`; + + return ( +
+
+
+ + ← Back to dashboard + +

{link.invoiceNumber}

+
+ +

{link.customerName}

+
+
+
+ {!canAccessReceipt(link) && } + {canAccessReceipt(link) && ( + <> + + View receipt + + + + )} +
+
+ +
+
+

Payment methods

+

+ { + PAYMENT_METHODS_OPTIONS.find( + (option) => option.value === link.allowedPaymentMethods, + )?.label + } +

+
+
+

Payment link

+ + {payUrl} + +
+
+ + + + + + + {canAccessReceipt(link) && ( +
+

Customer receipt

+

+ Preview what the customer sees. Use "View receipt" above to + open the printable receipt or email it directly. +

+
+ +
+
+ )} + + +
+ ); +} diff --git a/src/app/admin/payment-links/new/page.tsx b/src/app/admin/payment-links/new/page.tsx new file mode 100644 index 0000000..781a317 --- /dev/null +++ b/src/app/admin/payment-links/new/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; +import { getAdminSession } from "@/lib/auth/session"; +import { PaymentLinkBuilder } from "@/components/admin/PaymentLinkBuilder"; + +export default async function NewPaymentLinkPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + return ; +} diff --git a/src/app/admin/withdraw/page.tsx b/src/app/admin/withdraw/page.tsx new file mode 100644 index 0000000..c0ebb06 --- /dev/null +++ b/src/app/admin/withdraw/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from "next/navigation"; +import { WithdrawPanel } from "@/components/admin/WithdrawPanel"; +import { getAdminSession } from "@/lib/auth/session"; +import { isKPayTestMode } from "@/lib/kpay/environment"; + +export default async function WithdrawPage() { + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const environment = isKPayTestMode() ? "test" : "production"; + return ; +} diff --git a/src/app/api/admin/kpay/balance/route.ts b/src/app/api/admin/kpay/balance/route.ts new file mode 100644 index 0000000..5a3b240 --- /dev/null +++ b/src/app/api/admin/kpay/balance/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + isKPayTestMode, + type PaymentEnvironment, +} from "@/lib/kpay/environment"; +import { fetchWalletSnapshot } from "@/lib/kpay/wallets"; + +function parseEnvironment(value: string | null): PaymentEnvironment | null { + if (value === "production" || value === "test") return value; + return null; +} + +export async function GET(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const environment = + parseEnvironment(searchParams.get("environment")) ?? + (isKPayTestMode() ? "test" : "production"); + + const snapshot = await fetchWalletSnapshot(environment); + return NextResponse.json(snapshot); +} diff --git a/src/app/api/admin/kpay/withdraw/route.ts b/src/app/api/admin/kpay/withdraw/route.ts new file mode 100644 index 0000000..1c5f00c --- /dev/null +++ b/src/app/api/admin/kpay/withdraw/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { initWithdraw } from "@/lib/kpay/client"; + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + amount?: number; + provider?: string; + phoneNumber?: string; + description?: string; + }; + + const amount = Number(body.amount); + const provider = body.provider?.trim() ?? ""; + const phoneNumber = body.phoneNumber?.replace(/\D/g, "") ?? ""; + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + if (!provider || !phoneNumber) { + return NextResponse.json( + { error: "Provider and phone number are required" }, + { status: 400 }, + ); + } + + const withdrawal = await initWithdraw({ + amount, + provider, + phoneNumber, + externalId: `WD-${crypto.randomUUID()}`, + description: body.description?.trim() || "Stackwise wallet withdrawal", + }); + + return NextResponse.json({ withdrawal }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Withdrawal failed" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/admin/login/otp/send/route.ts b/src/app/api/admin/login/otp/send/route.ts new file mode 100644 index 0000000..81cd3f3 --- /dev/null +++ b/src/app/api/admin/login/otp/send/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { OtpLockedError } from "@/lib/otp/lockout"; +import { OtpThrottleError } from "@/lib/otp/throttle"; +import { sendAdminLoginOtp } from "@/lib/otp/admin"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { email?: string }; + const email = body.email?.trim() ?? ""; + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const result = await sendAdminLoginOtp(email); + return NextResponse.json(result); + } catch (error) { + if (error instanceof OtpLockedError || error instanceof OtpThrottleError) { + return NextResponse.json({ error: error.message }, { status: 429 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unable to send code" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/login/otp/verify/route.ts b/src/app/api/admin/login/otp/verify/route.ts new file mode 100644 index 0000000..f04095d --- /dev/null +++ b/src/app/api/admin/login/otp/verify/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { createSessionToken, setSessionCookie } from "@/lib/auth/session"; +import { OtpLockedError } from "@/lib/otp/lockout"; +import { verifyAdminLoginOtp } from "@/lib/otp/admin"; + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { email?: string; otp?: string }; + const email = body.email?.trim() ?? ""; + const otp = body.otp?.trim() ?? ""; + + if (!email || !otp) { + return NextResponse.json( + { error: "Email and code are required" }, + { status: 400 }, + ); + } + + const verified = await verifyAdminLoginOtp(email, otp); + const token = await createSessionToken(verified.email); + await setSessionCookie(token); + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof OtpLockedError) { + return NextResponse.json({ error: error.message }, { status: 429 }); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : "Invalid code" }, + { status: 401 }, + ); + } +} diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts new file mode 100644 index 0000000..a723add --- /dev/null +++ b/src/app/api/admin/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "@/lib/auth/session"; + +export async function POST() { + await clearSessionCookie(); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/payment-links/[id]/record-payment/route.ts b/src/app/api/admin/payment-links/[id]/record-payment/route.ts new file mode 100644 index 0000000..ef652de --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/record-payment/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { + isManualPaymentMethod, + type ManualPaymentMethod, +} from "@/lib/payments/manual-payment-methods"; +import { recordManualPayment } from "@/lib/payments/manual-payment"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + let body: { + paymentMethod?: string; + amountReceivedUsd?: number; + amountReceivedLocal?: number; + collectedAt?: string; + paymentReference?: string; + paymentNotes?: string; + sendReceipt?: boolean; + }; + + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body.paymentMethod || !isManualPaymentMethod(body.paymentMethod)) { + return NextResponse.json({ error: "Invalid payment method" }, { status: 400 }); + } + + if (!body.paymentReference?.trim()) { + return NextResponse.json({ error: "Payment reference is required" }, { status: 400 }); + } + + if (!body.collectedAt) { + return NextResponse.json({ error: "Collection time is required" }, { status: 400 }); + } + + const collectedAt = new Date(body.collectedAt).getTime(); + if (!Number.isFinite(collectedAt)) { + return NextResponse.json({ error: "Invalid collection time" }, { status: 400 }); + } + + try { + const link = await recordManualPayment( + id, + { + paymentMethod: body.paymentMethod as ManualPaymentMethod, + amountReceivedUsd: Number(body.amountReceivedUsd), + amountReceivedLocal: Number(body.amountReceivedLocal), + collectedAt, + paymentReference: body.paymentReference, + paymentNotes: body.paymentNotes, + sendReceipt: body.sendReceipt, + }, + session.email, + ); + return NextResponse.json({ link }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to record payment"; + const status = message.includes("not found") + ? 404 + : message.includes("already marked") + ? 409 + : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts b/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts new file mode 100644 index 0000000..878ff26 --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/resend-receipt/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { resendPaymentReceipt } from "@/lib/payments/manual-payment"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + try { + const link = await resendPaymentReceipt(id); + return NextResponse.json({ link }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to send receipt"; + const status = message.includes("not found") ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/admin/payment-links/[id]/route.ts b/src/app/api/admin/payment-links/[id]/route.ts new file mode 100644 index 0000000..8fc8cc0 --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById } from "@/lib/db/payment-links"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ link }); +} diff --git a/src/app/api/admin/payment-links/[id]/send/route.ts b/src/app/api/admin/payment-links/[id]/send/route.ts new file mode 100644 index 0000000..1f9708e --- /dev/null +++ b/src/app/api/admin/payment-links/[id]/send/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { getPaymentLinkById, savePaymentLink } from "@/lib/db/payment-links"; +import { sendPaymentLinkEmail } from "@/lib/emails/payments"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const link = await getPaymentLinkById(id); + if (!link) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + await sendPaymentLinkEmail(link); + const updated = await savePaymentLink({ + ...link, + status: link.status === "DRAFT" ? "SENT" : link.status, + sentAt: link.sentAt ?? Date.now(), + updatedAt: Date.now(), + }); + return NextResponse.json({ link: updated }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to send email" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/payment-links/route.ts b/src/app/api/admin/payment-links/route.ts new file mode 100644 index 0000000..b071d9a --- /dev/null +++ b/src/app/api/admin/payment-links/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getAdminSession } from "@/lib/auth/session"; +import { listPaymentLinks } from "@/lib/db/payment-links"; +import { createPaymentLink } from "@/lib/payments/actions"; +import { sendPaymentLinkEmail } from "@/lib/emails/payments"; +import type { LineItem } from "@/lib/payments/types"; +import type { PaymentMethodsOption } from "@/lib/payments/payment-methods"; + +export async function GET() { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const links = await listPaymentLinks(); + return NextResponse.json({ links }); +} + +export async function POST(request: Request) { + const session = await getAdminSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = (await request.json()) as { + customerName?: string; + customerEmail?: string; + currency?: string; + lineItems?: LineItem[]; + notes?: string; + sendEmail?: boolean; + allowedPaymentMethods?: PaymentMethodsOption; + }; + + if (!body.customerName?.trim() || !body.customerEmail?.trim()) { + return NextResponse.json( + { error: "Customer name and email are required" }, + { status: 400 }, + ); + } + if (!body.lineItems?.length) { + return NextResponse.json( + { error: "At least one service is required" }, + { status: 400 }, + ); + } + + const link = await createPaymentLink({ + customerName: body.customerName, + customerEmail: body.customerEmail, + currency: body.currency ?? process.env.DEFAULT_PAYMENT_CURRENCY ?? "XAF", + lineItems: body.lineItems, + notes: body.notes, + allowedPaymentMethods: body.allowedPaymentMethods, + }); + + let saved = link; + if (body.sendEmail) { + await sendPaymentLinkEmail(link); + const { savePaymentLink } = await import("@/lib/db/payment-links"); + saved = await savePaymentLink({ + ...link, + status: "SENT", + sentAt: Date.now(), + updatedAt: Date.now(), + }); + } + + return NextResponse.json({ link: saved }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create link" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts new file mode 100644 index 0000000..8bd8a73 --- /dev/null +++ b/src/app/api/exchange-rate/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { convertUsdToCurrency } from "@/lib/currency"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const from = searchParams.get("from") ?? "USD"; + const to = searchParams.get("to") ?? "XAF"; + const amount = Number.parseFloat(searchParams.get("amount") ?? "1"); + + if (!Number.isFinite(amount) || amount <= 0) { + return NextResponse.json({ error: "Invalid amount" }, { status: 400 }); + } + + try { + const { amount: converted, rate } = await convertUsdToCurrency(amount, to); + return NextResponse.json({ + from, + to, + rate, + amount, + converted, + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Conversion failed" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/pay/[slug]/init/route.ts b/src/app/api/pay/[slug]/init/route.ts new file mode 100644 index 0000000..3d27e8d --- /dev/null +++ b/src/app/api/pay/[slug]/init/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import type { CheckoutPaymentMethod } from "@/lib/kpay/buildGatewayInit"; +import { initiateKPayPayment } from "@/lib/payments/actions"; + +function parseMethod(value: unknown): CheckoutPaymentMethod { + if (value === "MOBILE_MONEY") return "MOBILE_MONEY"; + return "CARD"; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + + try { + const body = (await request.json().catch(() => ({}))) as { + method?: unknown; + }; + const method = parseMethod(body.method); + const { link, gatewayUrl } = await initiateKPayPayment(slug, method); + recordPaymentAudit(link.id, "PAYMENT_INIT", request.headers, { method }); + return NextResponse.json({ gatewayUrl }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Payment initiation failed"; + const status = message.includes("timed out") ? 504 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/webhooks/deposits/route.ts b/src/app/api/webhooks/deposits/route.ts new file mode 100644 index 0000000..9120a06 --- /dev/null +++ b/src/app/api/webhooks/deposits/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay deposit (payment in) notifications — payment.initiated, payment.completed, etc. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/kpay/route.ts b/src/app/api/webhooks/kpay/route.ts new file mode 100644 index 0000000..0841fb8 --- /dev/null +++ b/src/app/api/webhooks/kpay/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** Generic KPay callback — receives all event types when specific URLs are not set. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/payouts/route.ts b/src/app/api/webhooks/payouts/route.ts new file mode 100644 index 0000000..63f4669 --- /dev/null +++ b/src/app/api/webhooks/payouts/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay payout (payment out) notifications. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/api/webhooks/refunds/route.ts b/src/app/api/webhooks/refunds/route.ts new file mode 100644 index 0000000..129c01e --- /dev/null +++ b/src/app/api/webhooks/refunds/route.ts @@ -0,0 +1,6 @@ +import { handleKPayWebhookRequest } from "@/lib/kpay/webhook"; + +/** KPay refund notifications. */ +export async function POST(request: Request) { + return handleKPayWebhookRequest(request); +} diff --git a/src/app/pay/(checkout)/[slug]/page.tsx b/src/app/pay/(checkout)/[slug]/page.tsx new file mode 100644 index 0000000..0b13528 --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/page.tsx @@ -0,0 +1,96 @@ +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; +import Mark from "@/components/Mark"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { PaymentLegalNotice } from "@/components/payments/PaymentLegalNotice"; +import { PayButton } from "@/components/payments/PayButton"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { getPaymentLinkBySlug } from "@/lib/db/payment-links"; +import { syncPaymentStatusFromKPay } from "@/lib/payments/actions"; +import { SITE_NAME } from "@/lib/content"; + +async function maybeSyncPendingLink(link: Awaited>) { + if (!link?.kpayPaymentId || link.status !== "PENDING") return link; + + try { + return await Promise.race([ + syncPaymentStatusFromKPay(link), + new Promise((resolve) => { + setTimeout(() => resolve(link), 8_000); + }), + ]); + } catch { + return link; + } +} + +export default async function PayPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + let link = await getPaymentLinkBySlug(slug); + if (!link) notFound(); + + link = (await maybeSyncPendingLink(link)) ?? link; + + recordPaymentAudit(link.id, "PAY_PAGE_VIEW", await headers()); + + const isPaid = link.status === "PAID"; + + return ( +
+
+ +
+

{SITE_NAME}

+

Secure payment

+
+
+ + {isPaid ? ( +
+

Payment received

+

+ Thank you, {link.customerName}. Your payment for {link.invoiceNumber} is complete. +

+ + View receipt + +
+ ) : link.status === "FAILED" || link.status === "CANCELLED" ? ( +
+ Previous payment attempt {link.status.toLowerCase()}. You can try again below. +
+ ) : null} + + + + {!isPaid && ( + <> +
+ +
+
+ +
+ + )} + + +
+ ); +} diff --git a/src/app/pay/(checkout)/[slug]/receipt/page.tsx b/src/app/pay/(checkout)/[slug]/receipt/page.tsx new file mode 100644 index 0000000..401a460 --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/receipt/page.tsx @@ -0,0 +1,48 @@ +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { PrintButton } from "@/components/payments/PrintButton"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { getPaymentLinkBySlug } from "@/lib/db/payment-links"; +import { COMPANY_WEBSITE, COMPANY_WEBSITE_URL } from "@/lib/company"; +import { SITE_NAME } from "@/lib/content"; +import { canAccessReceipt } from "@/lib/payments/receipt-access"; + +export default async function ReceiptPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const link = await getPaymentLinkBySlug(slug); + if (!link) notFound(); + + if (!canAccessReceipt(link)) { + redirect(`/pay/${slug}`); + } + + recordPaymentAudit(link.id, "RECEIPT_VIEW", await headers()); + + return ( +
+
+

{SITE_NAME} · Receipt

+ +
+ + + +

+ {SITE_NAME} ·{" "} + + {COMPANY_WEBSITE} + +

+
+ ); +} diff --git a/src/app/pay/(checkout)/[slug]/return/page.tsx b/src/app/pay/(checkout)/[slug]/return/page.tsx new file mode 100644 index 0000000..774891b --- /dev/null +++ b/src/app/pay/(checkout)/[slug]/return/page.tsx @@ -0,0 +1,51 @@ +import { headers } from "next/headers"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { recordPaymentAudit } from "@/lib/db/payment-audit"; +import { handleGatewayReturn } from "@/lib/payments/return"; + +export default async function PayReturnPage({ + params, + searchParams, +}: { + params: Promise<{ slug: string }>; + searchParams: Promise>; +}) { + const { slug } = await params; + const query = await searchParams; + + const handled = await handleGatewayReturn(slug, query); + if (!handled) notFound(); + + const { link, result } = handled; + recordPaymentAudit(link.id, "RETURN_CALLBACK", await headers(), { + outcome: result.outcome, + }); + + if (result.outcome === "receipt") { + redirect(`/pay/${slug}/receipt`); + } + + if (result.outcome === "pay") { + redirect(`/pay/${slug}`); + } + + return ( +
+

+ {result.outcome === "cancelled" ? "Payment cancelled" : "Payment status pending"} +

+

+ {result.outcome === "cancelled" + ? "You cancelled the payment. You can try again when ready." + : "We are confirming your payment. This may take a moment."} +

+ + Back to invoice + +
+ ); +} diff --git a/src/app/pay/(checkout)/layout.tsx b/src/app/pay/(checkout)/layout.tsx new file mode 100644 index 0000000..19f0cd8 --- /dev/null +++ b/src/app/pay/(checkout)/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { createPageMetadata } from "@/lib/metadata"; +import { ClientProviders } from "@/components/ClientProviders"; +import { SITE_NAME } from "@/lib/content"; +import "../../../styles/globals.css"; +import "../../../styles/print-receipt.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: `Pay | ${SITE_NAME}`, + description: `Secure invoice payment for ${SITE_NAME}. Pay by card or Mobile Money via KPay.`, + path: "/pay", + noIndex: true, +}); + +export default function PayCheckoutLayout({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/src/app/pay/(legal)/layout.tsx b/src/app/pay/(legal)/layout.tsx new file mode 100644 index 0000000..bdd3d86 --- /dev/null +++ b/src/app/pay/(legal)/layout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { createPageMetadata } from "@/lib/metadata"; +import { ClientProviders } from "@/components/ClientProviders"; +import { SITE_NAME } from "@/lib/content"; +import "../../../styles/globals.css"; +import "../../../styles/pay-legal.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata = createPageMetadata({ + title: `Payment processing & data | ${SITE_NAME}`, + description: + "How Stackwise Technologies handles payment processing, customer data, receipts, and KPay checkout.", + path: "/pay/legal", +}); + +export default function PayLegalLayout({ children }: { children: ReactNode }) { + return ( + + + +
{children}
+
+ + + ); +} diff --git a/src/app/pay/(legal)/legal/page.tsx b/src/app/pay/(legal)/legal/page.tsx new file mode 100644 index 0000000..0dd30f7 --- /dev/null +++ b/src/app/pay/(legal)/legal/page.tsx @@ -0,0 +1,104 @@ +import Link from "next/link"; +import Mark from "@/components/Mark"; +import { + COMPANY_RC, + COMPANY_WEBSITE, + COMPANY_WEBSITE_URL, +} from "@/lib/company"; +import { EMAIL, SITE_NAME } from "@/lib/content"; + +export default function PaymentLegalPage() { + return ( +
+
+ +
+

{SITE_NAME}

+

Payment processing & data notice

+
+
+ +
+

+ How we handle payments and your data +

+

+ This notice applies when you pay an invoice issued by {SITE_NAME}{" "} + through our secure payment pages ({COMPANY_WEBSITE}/pay/…). +

+
+ +
+

Payment processor

+

+ Card and Mobile Money payments are processed by{" "} + + KPay + + . When you choose a payment method you are redirected to KPay's + hosted checkout. KPay collects the information required to complete + the transaction under their terms and privacy policy. +

+
+ +
+

What we collect

+
    +
  • Name and email address provided on the invoice
  • +
  • Invoice line items, amounts, and currency conversion rates
  • +
  • Payment status, KPay reference, and completion timestamp
  • +
  • + IP address and browser user agent on pay pages, stored for internal + auditing (not shared with third parties) +
  • +
+

+ We do not receive or store card numbers, CVV codes, + or mobile-money PINs. +

+
+ +
+

Receipts

+

+ After a successful payment we email a receipt link to the address on + the invoice. Receipt pages are only available once payment is + confirmed. You may print the receipt for your records; it is + formatted for A4 paper. +

+
+ +
+

Data retention

+

+ Payment records are retained for accounting, tax, and dispute + resolution for as long as required by applicable law and our internal + policies. +

+
+ +
+

Contact

+

+ {SITE_NAME} · RC: {COMPANY_RC} +
+ Email:{" "} + + {EMAIL} + +
+ Web:{" "} + + {COMPANY_WEBSITE} + +

+
+ +

+ + ← {COMPANY_WEBSITE} + +

+
+ ); +} diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx new file mode 100644 index 0000000..4fc1f30 --- /dev/null +++ b/src/components/ClientProviders.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ToastProvider } from "@/components/ToastProvider"; + +export function ClientProviders({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/components/CopyCommand.tsx b/src/components/CopyCommand.tsx index 2e39d4f..368b34f 100644 --- a/src/components/CopyCommand.tsx +++ b/src/components/CopyCommand.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { useToast } from "@/components/ToastProvider"; +import { BTN_PRESS } from "@/lib/ui/buttons"; export default function CopyCommand({ value, @@ -13,6 +15,7 @@ export default function CopyCommand({ copiedLabel: string; className?: string; }) { + const toast = useToast(); const [copied, setCopied] = useState(false); async function copy() { @@ -22,6 +25,7 @@ export default function CopyCommand({ window.setTimeout(() => setCopied(false), 1600); } catch { setCopied(false); + toast.error("Could not copy to clipboard."); } } @@ -29,7 +33,7 @@ export default function CopyCommand({ + + + {/* Mobile menu toggle */} + + + + {menuOpen && ( + + )} + + )} +
+ {children} +
+ + ); +} diff --git a/src/components/admin/EnvironmentBadge.tsx b/src/components/admin/EnvironmentBadge.tsx new file mode 100644 index 0000000..a8072bc --- /dev/null +++ b/src/components/admin/EnvironmentBadge.tsx @@ -0,0 +1,27 @@ +import type { PaymentEnvironment } from "@/lib/kpay/environment"; + +const styles: Record = { + production: "bg-emerald-950/80 text-emerald-300 ring-emerald-900/60", + test: "bg-amber-950/80 text-amber-300 ring-amber-900/60", +}; + +const labels: Record = { + production: "Production", + test: "Test", +}; + +export function EnvironmentBadge({ + environment, + compact = false, +}: { + environment: PaymentEnvironment; + compact?: boolean; +}) { + return ( + + {compact ? (environment === "test" ? "Test" : "Live") : labels[environment]} + + ); +} diff --git a/src/components/admin/ManualPaymentSummary.tsx b/src/components/admin/ManualPaymentSummary.tsx new file mode 100644 index 0000000..62bc5d0 --- /dev/null +++ b/src/components/admin/ManualPaymentSummary.tsx @@ -0,0 +1,57 @@ +import { formatMoney } from "@/lib/currency"; +import { formatDateTime } from "@/lib/format-datetime"; +import { formatPaymentMethodLabel } from "@/lib/payments/manual-payment-methods"; +import { displayPaymentReference } from "@/lib/payments/payment-display"; +import type { PaymentLink } from "@/lib/payments/types"; + +export function ManualPaymentSummary({ link }: { link: PaymentLink }) { + if (link.status !== "PAID" || link.paymentSource !== "MANUAL") { + return null; + } + + const reference = displayPaymentReference(link); + const collectedAt = link.collectedAt ?? link.paidAt; + + return ( +
+

Manual payment recorded

+
+
+
Payment method
+
+ {formatPaymentMethodLabel(link.paymentMethod)} +
+
+
+
Time of collection
+
+ {collectedAt ? formatDateTime(collectedAt) : "—"} +
+
+
+
Amount received
+
+ ${(link.amountReceivedUsd ?? link.amountUsd).toFixed(2)} USD + {" · "} + {formatMoney( + link.amountReceivedLocal ?? link.amountLocal, + link.currency, + )} +
+
+
+
Payment reference
+
+ {reference ?? "—"} +
+
+ {link.paymentNotes && ( +
+
Payment notes
+
{link.paymentNotes}
+
+ )} +
+
+ ); +} diff --git a/src/components/admin/OtpInput.tsx b/src/components/admin/OtpInput.tsx new file mode 100644 index 0000000..9ce1ecc --- /dev/null +++ b/src/components/admin/OtpInput.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + type ClipboardEvent, + type KeyboardEvent, +} from "react"; + +type OtpInputProps = { + length?: number; + value: string; + onChange: (value: string) => void; + onComplete?: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + id?: string; +}; + +function digitsOnly(value: string): string { + return value.replace(/\D/g, ""); +} + +export function OtpInput({ + length = 6, + value, + onChange, + onComplete, + disabled = false, + autoFocus = false, + id = "otp-input", +}: OtpInputProps) { + const inputRefs = useRef>([]); + const digits = digitsOnly(value).slice(0, length).split(""); + while (digits.length < length) digits.push(""); + + const setValue = useCallback( + (next: string) => { + const cleaned = digitsOnly(next).slice(0, length); + onChange(cleaned); + if (cleaned.length === length) { + onComplete?.(cleaned); + } + }, + [length, onChange, onComplete], + ); + + useEffect(() => { + if (autoFocus && !disabled) { + inputRefs.current[0]?.focus(); + } + }, [autoFocus, disabled]); + + function focusAt(index: number) { + const clamped = Math.max(0, Math.min(index, length - 1)); + inputRefs.current[clamped]?.focus(); + inputRefs.current[clamped]?.select(); + } + + function applyDigits(startIndex: number, incoming: string) { + const chars = digitsOnly(incoming); + if (!chars) return; + + const next = value.split(""); + while (next.length < length) next.push(""); + + let cursor = startIndex; + for (const char of chars) { + if (cursor >= length) break; + next[cursor] = char; + cursor += 1; + } + + setValue(next.join("")); + focusAt(Math.min(cursor, length - 1)); + } + + function handleChange(index: number, raw: string) { + const char = digitsOnly(raw); + if (!char) { + const next = value.split(""); + while (next.length < length) next.push(""); + next[index] = ""; + setValue(next.join("")); + return; + } + + applyDigits(index, char.slice(-1)); + } + + function handleKeyDown(index: number, event: KeyboardEvent) { + if (event.key === "Backspace") { + event.preventDefault(); + const next = value.split(""); + while (next.length < length) next.push(""); + + if (next[index]) { + next[index] = ""; + setValue(next.join("")); + focusAt(index); + return; + } + + if (index > 0) { + next[index - 1] = ""; + setValue(next.join("")); + focusAt(index - 1); + } + return; + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + focusAt(index - 1); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + focusAt(index + 1); + return; + } + + if (event.key === "Delete") { + event.preventDefault(); + const next = value.split(""); + while (next.length < length) next.push(""); + next[index] = ""; + setValue(next.join("")); + return; + } + + if (event.key.length === 1 && !/^\d$/.test(event.key)) { + event.preventDefault(); + } + } + + function handlePaste(event: ClipboardEvent) { + event.preventDefault(); + const pasted = event.clipboardData.getData("text"); + applyDigits(0, pasted); + } + + return ( +
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + id={index === 0 ? id : undefined} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? "one-time-code" : "off"} + pattern="[0-9]*" + maxLength={1} + value={digit} + disabled={disabled} + aria-label={`Digit ${index + 1} of ${length}`} + className="h-12 w-10 rounded-lg border border-line bg-background text-center text-lg font-semibold tabular-nums text-foreground outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-50 sm:h-14 sm:w-12 sm:text-xl" + onChange={(event) => handleChange(index, event.target.value)} + onKeyDown={(event) => handleKeyDown(index, event)} + onPaste={handlePaste} + onFocus={(event) => event.currentTarget.select()} + /> + ))} +
+ ); +} diff --git a/src/components/admin/PaymentAuditLog.tsx b/src/components/admin/PaymentAuditLog.tsx new file mode 100644 index 0000000..265ff08 --- /dev/null +++ b/src/components/admin/PaymentAuditLog.tsx @@ -0,0 +1,18 @@ +import { PaymentAuditLogsPanel } from "@/components/admin/PaymentAuditLogsPanel"; +import { listPaymentAuditLogs } from "@/lib/db/payment-audit"; + +export async function PaymentAuditLog({ paymentLinkId }: { paymentLinkId: string }) { + const logs = await listPaymentAuditLogs(paymentLinkId, 500); + + return ( +
+

Access log

+

+ IP address and browser info for internal auditing. +

+
+ +
+
+ ); +} diff --git a/src/components/admin/PaymentAuditLogTable.tsx b/src/components/admin/PaymentAuditLogTable.tsx new file mode 100644 index 0000000..0b5f14b --- /dev/null +++ b/src/components/admin/PaymentAuditLogTable.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; +import type { PaymentAuditLog, PaymentAuditLogWithLink } from "@/lib/db/payment-audit"; +import { + formatAuditTimestamp, + PAYMENT_AUDIT_EVENT_LABELS, +} from "@/lib/payments/audit-labels"; + +type AuditRow = PaymentAuditLog | PaymentAuditLogWithLink; + +function hasLinkContext(log: AuditRow): log is PaymentAuditLogWithLink { + return "invoiceNumber" in log && "customerName" in log; +} + +export function PaymentAuditLogTable({ + logs, + showInvoice = false, + emptyMessage = "No access logs match your filters.", +}: { + logs: AuditRow[]; + showInvoice?: boolean; + emptyMessage?: string; +}) { + const columnCount = showInvoice ? 5 : 4; + + return ( +
+ + + + + {showInvoice && } + + + + + + + {logs.length === 0 ? ( + + + + ) : ( + logs.map((log) => ( + + + {showInvoice && ( + + )} + + + + + )) + )} + +
WhenInvoiceEventIPUser agent
+ {emptyMessage} +
+ {formatAuditTimestamp(log.createdAt)} + + {hasLinkContext(log) ? ( + <> + + {log.invoiceNumber} + +

{log.customerName}

+ + ) : ( + "—" + )} +
+ {PAYMENT_AUDIT_EVENT_LABELS[log.event] ?? log.event} + {log.ipAddress ?? "—"} + {log.userAgent ?? "—"} +
+
+ ); +} diff --git a/src/components/admin/PaymentAuditLogsPanel.tsx b/src/components/admin/PaymentAuditLogsPanel.tsx new file mode 100644 index 0000000..afdf623 --- /dev/null +++ b/src/components/admin/PaymentAuditLogsPanel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { PaymentAuditLogTable } from "@/components/admin/PaymentAuditLogTable"; +import type { + PaymentAuditEvent, + PaymentAuditLog, + PaymentAuditLogWithLink, +} from "@/lib/db/payment-audit"; +import { ADMIN_TABLE_PAGE_SIZE, paginate } from "@/lib/admin/pagination"; +import { TablePagination } from "@/components/admin/TablePagination"; +import { PAYMENT_AUDIT_EVENT_LABELS } from "@/lib/payments/audit-labels"; + +const EVENT_OPTIONS: Array = [ + "ALL", + "PAY_PAGE_VIEW", + "PAYMENT_INIT", + "RECEIPT_VIEW", + "RETURN_CALLBACK", + "MANUAL_PAYMENT_RECORDED", +]; + +type AuditRow = PaymentAuditLog | PaymentAuditLogWithLink; + +export function PaymentAuditLogsPanel({ + logs, + showInvoice = false, + totalCount, +}: { + logs: AuditRow[]; + showInvoice?: boolean; + totalCount?: number; +}) { + const [query, setQuery] = useState(""); + const [event, setEvent] = useState("ALL"); + const [page, setPage] = useState(1); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return logs.filter((log) => { + if (event !== "ALL" && log.event !== event) return false; + if (!q) return true; + + const invoice = + "invoiceNumber" in log ? String(log.invoiceNumber).toLowerCase() : ""; + const customer = + "customerName" in log ? String(log.customerName).toLowerCase() : ""; + + return ( + invoice.includes(q) || + customer.includes(q) || + (log.ipAddress ?? "").toLowerCase().includes(q) || + (log.userAgent ?? "").toLowerCase().includes(q) || + (PAYMENT_AUDIT_EVENT_LABELS[log.event] ?? log.event) + .toLowerCase() + .includes(q) + ); + }); + }, [logs, query, event]); + + const { + items: pageLogs, + currentPage, + totalPages, + totalItems, + pageSize, + } = paginate(filtered, page, ADMIN_TABLE_PAGE_SIZE); + + function resetPage() { + setPage(1); + } + + const loadedCount = totalCount ?? logs.length; + + return ( +
+
+ + +
+ +

+ {filtered.length} log{filtered.length === 1 ? "" : "s"} + {filtered.length !== loadedCount + ? ` (filtered from ${loadedCount})` + : ""} +

+ +
+ +
+ + +
+ ); +} diff --git a/src/components/admin/PaymentLinkBuilder.tsx b/src/components/admin/PaymentLinkBuilder.tsx new file mode 100644 index 0000000..8296974 --- /dev/null +++ b/src/components/admin/PaymentLinkBuilder.tsx @@ -0,0 +1,504 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { InvoiceView } from "@/components/payments/InvoiceView"; +import { useToast } from "@/components/ToastProvider"; +import { BTN_GHOST, BTN_PRESS, BTN_PRIMARY, BTN_SECONDARY } from "@/lib/ui/buttons"; +import { formatMoney } from "@/lib/currency"; +import { KPAY_CARD_MAX_USD } from "@/lib/kpay/buildGatewayInit"; +import { SERVICES_CATALOG } from "@/lib/services-catalog"; +import { + PAYMENT_METHODS_OPTIONS, + type PaymentMethodsOption, +} from "@/lib/payments/payment-methods"; +import { SUPPORTED_CURRENCIES } from "@/lib/payments/types"; +import type { LineItem, PaymentLink } from "@/lib/payments/types"; + +type SelectedItem = LineItem & { key: string }; + +function newKey() { + return crypto.randomUUID(); +} + +export function PaymentLinkBuilder() { + const router = useRouter(); + const toast = useToast(); + const [customerName, setCustomerName] = useState(""); + const [customerEmail, setCustomerEmail] = useState(""); + const [currency, setCurrency] = useState("KES"); + const [allowedPaymentMethods, setAllowedPaymentMethods] = + useState("BOTH"); + const [notes, setNotes] = useState(""); + const [sendEmail, setSendEmail] = useState(false); + const [items, setItems] = useState([]); + const [fxResult, setFxResult] = useState<{ + key: string; + converted: number; + rate: number; + } | null>(null); + const [fxError, setFxError] = useState<{ key: string; message: string } | null>( + null, + ); + const [loading, setLoading] = useState(false); + const [previewMode, setPreviewMode] = useState<"invoice" | "receipt">("invoice"); + + const totalUsd = useMemo( + () => items.reduce((sum, item) => sum + item.quantity * item.unitPriceUsd, 0), + [items], + ); + + const fxRequestKey = totalUsd > 0 ? `${currency}:${totalUsd}` : null; + const fx = + fxRequestKey && fxResult?.key === fxRequestKey ? fxResult : null; + const fxErrorMessage = + fxError?.key === fxRequestKey ? fxError.message : null; + const fxLoading = Boolean( + fxRequestKey && fxResult?.key !== fxRequestKey && !fxErrorMessage, + ); + + useEffect(() => { + if (!fxRequestKey) return; + + const controller = new AbortController(); + fetch( + `/api/exchange-rate?from=USD&to=${currency}&amount=${totalUsd}`, + { signal: controller.signal }, + ) + .then(async (res) => { + const data = (await res.json()) as { + converted?: number; + rate?: number; + error?: string; + }; + if (!res.ok) { + throw new Error(data.error ?? "Unable to fetch exchange rate"); + } + if (data.converted == null || data.rate == null) { + throw new Error("Invalid exchange rate response"); + } + setFxResult({ + key: fxRequestKey, + converted: data.converted, + rate: data.rate, + }); + setFxError(null); + }) + .catch((err: Error) => { + if (err.name !== "AbortError") { + setFxError({ + key: fxRequestKey, + message: err.message || "Unable to fetch exchange rate", + }); + } + }); + + return () => controller.abort(); + }, [fxRequestKey, currency, totalUsd]); + + const previewLink: PaymentLink | null = useMemo(() => { + if (items.length === 0) return null; + const now = 1_700_000_000_000; + const lineItems: LineItem[] = items.map( + ({ serviceId, name, description, quantity, unitPriceUsd }) => ({ + serviceId, + name, + description, + quantity, + unitPriceUsd, + }), + ); + return { + id: "preview", + slug: "preview", + customerName: customerName.trim() || "Customer name", + customerEmail: customerEmail.trim() || "customer@example.com", + currency, + amountUsd: totalUsd, + amountLocal: fx?.converted ?? 0, + exchangeRate: fx?.rate ?? 0, + status: previewMode === "receipt" ? "PAID" : "DRAFT", + lineItems, + notes: notes.trim() || null, + kpayPaymentId: null, + kpayReference: previewMode === "receipt" ? "PREVIEW-REF" : null, + kpayIsTest: null, + gatewayUrl: null, + allowedPaymentMethods, + invoiceNumber: "STL-PREVIEW", + sentAt: null, + paidAt: previewMode === "receipt" ? now : null, + receiptSentAt: null, + paymentSource: previewMode === "receipt" ? "MANUAL" : null, + paymentMethod: previewMode === "receipt" ? "BANK_TRANSFER" : null, + amountReceivedUsd: previewMode === "receipt" ? totalUsd : null, + amountReceivedLocal: previewMode === "receipt" ? (fx?.converted ?? 0) : null, + collectedAt: previewMode === "receipt" ? now : null, + paymentReference: previewMode === "receipt" ? "PREVIEWREF" : null, + paymentNotes: previewMode === "receipt" ? "Sample payment notes" : null, + createdAt: now, + updatedAt: now, + }; + }, [ + items, + customerName, + customerEmail, + currency, + totalUsd, + fx?.converted, + fx?.rate, + allowedPaymentMethods, + notes, + previewMode, + ]); + + function addService(serviceId: string) { + const service = SERVICES_CATALOG.find((s) => s.id === serviceId); + if (!service) return; + setItems((prev) => [ + ...prev, + { + key: newKey(), + serviceId: service.id, + name: service.name, + description: service.description, + quantity: 1, + unitPriceUsd: service.defaultPriceUsd, + }, + ]); + } + + function updateItem(key: string, patch: Partial) { + setItems((prev) => + prev.map((item) => (item.key === key ? { ...item, ...patch } : item)), + ); + } + + function removeItem(key: string) { + setItems((prev) => prev.filter((item) => item.key !== key)); + } + + async function submit(saveOnly: boolean) { + setLoading(true); + try { + if (!customerName.trim() || !customerEmail.trim()) { + throw new Error("Customer name and email are required"); + } + + const lineItems: LineItem[] = items.map( + ({ serviceId, name, description, quantity, unitPriceUsd }) => ({ + serviceId, + name, + description, + quantity, + unitPriceUsd, + }), + ); + + const res = await fetch("/api/admin/payment-links", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + customerName, + customerEmail, + currency, + lineItems, + notes, + allowedPaymentMethods, + sendEmail: saveOnly ? false : sendEmail, + }), + }); + const data = (await res.json()) as { link?: { id: string }; error?: string }; + if (!res.ok) throw new Error(data.error ?? "Failed to create payment link"); + + router.push(`/admin/payment-links/${data.link!.id}`); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Create payment link

+

+ Build the invoice, preview it live, then set the recipient and create the link. +

+
+ +
+
+
+ + + + +
+ +
+
+

Services

+ +
+ + {items.length === 0 ? ( +

Add at least one service.

+ ) : ( +
+ {items.map((item) => ( +
+
+ updateItem(item.key, { name: e.target.value })} + className="w-full rounded border border-line bg-panel px-2 py-1 text-sm font-medium" + /> + + updateItem(item.key, { description: e.target.value }) + } + className="mt-2 w-full rounded border border-line bg-panel px-2 py-1 text-xs text-muted" + placeholder="Description" + /> +
+ + + +
+ ))} +
+ )} +
+ +
+