BorderReady
BorderReady, 영국에서 독일·프랑스·네덜란으로 팔레트 운송을 예약합니다
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
Event ticketing & payments SaaS for Kenya: M-Pesa STK/B2C/B2B, QR gate check-in (offline), payouts, WhatsApp. Next.js + TypeScript + Prisma.
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Event ticketing and payments platform for Kenya. Organisers sell tickets through M-Pesa and card, check people in at the gate (even offline), and withdraw their earnings to M-Pesa or a bank, with every shilling reconciled.
Live: tixflow-pxml.onrender.com
src/lib/pricing.ts, src/lib/checkoutQuote.ts), so the total shown is the total charged, whatever the client sends.Next.js (pages + API routes, TypeScript)
├─ src/lib/ domain logic: checkout, pricing, payouts, refunds, gate, promoters ...
├─ src/pages/api/ REST endpoints: checkout, mpesa, intasend, tickets, staff, platform-admin
├─ src/pages/api/cron reconcile + scheduled jobs (reminders, expiries, surveys)
└─ prisma/ 30 models on MongoDB
Integrations: Safaricom Daraja (STK, B2C, B2B), IntaSend, WhatsApp, Africa's Talking SMS, Resend, Cloudinary
Hosting: one long-running Node service on Render, health-checked, cron every 5 minutes
npm install
# create .env with the variables listed in render.yaml (DATABASE_URL at minimum)
npx prisma db push
npm run dev # http://localhost:3000
The sections below are the operator's guide: payments setup, secrets, jobs and deployment.
All buyer payments go through M-Pesa STK Push (Daraja) into your one platform shortcode — configured via MPESA_* env vars. Tenants request payouts to their own M-Pesa or bank (via the bank's M-Pesa paybill); a platform admin reviews and approves each request from /platform-admin, which triggers the actual Daraja B2C (M-Pesa) or B2B (bank) transfer.
Before payouts work end-to-end you'll need to:
MPESA_B2C_INITIATOR_NAME and MPESA_B2C_INITIATOR_PASSWORD (used for both B2C and B2B) in .env.certs/sandbox_cert.cer (or certs/production_cert.cer) — see src/lib/mpesaB2C.ts for the download links.PLATFORM_FEE_PERCENT to whatever cut the platform takes per withdrawal (default 3, i.e. 3%) — this one value controls every future payout's fee.NEXT_PUBLIC_APP_URL to a publicly reachable URL (e.g. via ngrok in development) so Safaricom's STK, B2C, and B2B callbacks can reach /api/mpesa/*.MPESA_B2C_RESULT_URL / MPESA_B2B_RESULT_URL (and the _TIMEOUT_URL pair) are now optional — they default to ${NEXT_PUBLIC_APP_URL}/api/mpesa/b2c-result / b2b-result.In production the app refuses to run a route that needs a missing secret (there are no hardcoded fallbacks any more). Generate each with openssl rand -base64 32:
| Variable | Used for |
|---|---|
JWT_SECRET | Tenant login sessions (also the fallback for the two below) |
PLATFORM_ADMIN_JWT_SECRET | Platform-admin sessions — separate on purpose, no fallback |
TICKET_LOOKUP_JWT_SECRET | "Find my tickets" magic links (optional, falls back to JWT_SECRET) |
LINK_SIGNING_SECRET | Personal feedback-survey and certificate links (optional, falls back to JWT_SECRET) |
OTP_SECRET | Hashing emailed confirmation codes (optional, falls back to JWT_SECRET) |
MPESA_CALLBACK_SECRET | Appended to every Daraja callback URL; callbacks without it are rejected |
CRON_SECRET | Authorises /api/cron/reconcile and /api/cron/scheduled |
ASSISTANT_API_KEY | Authorises the website assistant's tools at /api/assistant/* (see docs/assistant/README.md) |
PLATFORM_ADMIN_USERNAME / PLATFORM_ADMIN_PASSWORD | Platform-admin login |
Daraja callbacks aren't guaranteed to arrive. /api/cron/reconcile asks Safaricom directly about any order still pending after ~45s (issuing tickets or failing it), expires orders that never resolve, and logs payouts stuck in processing for 30+ minutes. Run it every few minutes:
*/5 * * * * curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" https://<your-domain>/api/cron/reconcile
A buyer's checkout page also triggers the same check for their own order while it's waiting.
Payouts stuck in processing show up under "In progress" on /platform-admin. Check the M-Pesa portal, then mark each one Completed (with the transaction code) or Failed (returns the amount to the tenant's balance).
Login, platform-admin login, STK push, OTP and ticket-lookup endpoints are rate-limited in memory (src/lib/rateLimit.ts), which assumes the same single next start process as WhatsApp. If you run behind a reverse proxy, make sure it sets X-Forwarded-For itself rather than passing through a client-supplied one.
/api/cron/scheduled runs every time-based job other than payment reconciliation: Event Space invites, event reminders, Lipa Pole Pole reminders and expiry, and post-event feedback surveys. Run it every few minutes next to the reconcile job:
*/5 * * * * curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" https://<your-domain>/api/cron/scheduled
(/api/cron/space-invites is an alias for the same job.) Each run first backfills new schema defaults onto older documents, once per server start (src/lib/backfillDefaults.ts), because MongoDB where filters don't match fields that were never stored. Messages go out spaced apart, so the unofficial WhatsApp number isn't flagged for bulk sending.
After pulling schema changes, run npx prisma db push to create new collections and indexes. If API routes return 404 in npm run dev after routes have been renamed, delete .next/dev and restart: the dev server can keep a stale route cache.
Every event's dashboard page has a tools grid linking to the following.
/space/<CODE>); no account is needed. Each room has live polls (multiple choice, or open answers shown as a ranked word cloud), Q&A with upvotes and optional moderation, shared documents with a "follow the presenter" mode, and announcements. /space/<CODE>/screen is the projector view, controlled from the dashboard. Ticket holders get one email/WhatsApp message listing every room, 1 hour before the event (INVITE_LEAD_MS in src/lib/spaceInvites.ts). If the room is created after that, it goes out right away; later buyers get it with their ticket.
NEXT_PUBLIC_ALLOW_PDF_DOWNLOAD=true.src/lib/eventSpace.ts). Like WhatsApp and rate limiting, this assumes a single next start process./plan/<id>?key=…), and tickets are issued once it's fully paid. Reminders go out 7, 3 and 1 day before the deadline. Unpaid plans expire at the deadline and release their seats. The organiser can extend or reopen a plan (if the seats are still free) or cancel it. Money already paid stays in the balance; settle refunds with the buyer directly./certificate/<code>?t=…, "Save as PDF" from the browser). The attendee can set the name it shows. Sent from the Certificates page, or automatically with the feedback survey./exhibitor/<token>) to scan attendees' ticket QR codes at their stand, add notes and a hot/warm/cold rating, and export leads to CSV. Leads contain name and email only, never phone numbers.All numbers live in src/lib/plans.ts (plus env overrides).
PLATFORM_FEE_PERCENT (default 5) % per ticket, never less than MIN_TICKET_FEE (default KES 20), capped at the ticket's own price. It applies after any promo discount. The organiser chooses per event whether buyers pay it on top as a booking fee (passFeeToBuyer) or it comes out of their sales. The fee is stored on each order as platformFee. Cash box-office sales pay it too, deducted from the next payout. Paid events include 3 Event Space rooms, certificates and exhibitors. Withdrawals are free unless PAYOUT_FEE_PERCENT is set.comp), on paid events too. The first 300 are included; Plus/Pro raise it to 1,000 / unlimited. Paid tickets are never capped by the plan. This stops one cheap paid ticket type unlocking unlimited free registrations.event_plan order). Moving up from Plus to Pro costs the difference. Limits are enforced at registration, room creation, room join, and certificates/exhibitors.getTenantBalance) = M-Pesa and card takings − ticket fees − payouts (pending or done, including refunds) − commission owed to promoters. Event-plan purchases are platform revenue and never count towards it.src/lib/pricing.ts, src/lib/checkoutQuote.ts), so the shown total is the charged total./pay/<order>/<key>; IntaSend refuses redirect URLs with a query string. Set the IntaSend webhook to {APP_URL}/api/intasend/webhook with challenge INTASEND_WEBHOOK_CHALLENGE. Webhooks are never trusted on their own: the invoice is re-read from IntaSend with the secret key before fulfilling. The reconcile cron also asks IntaSend about pending card orders.src/lib/gate.ts; every scan is logged in ScanLog./api/events/<id>/scan-sync), flagging any the server disagrees with.RENTAL_DEVICE_PER_DAY / RENTAL_STAFF_PER_DAY. Requests appear under Platform admin → Gear and are emailed to PLATFORM_ALERT_EMAIL./lookup) before the event. The organiser approves (tickets cancelled, seats released, promo use returned) or rejects with a note. Approval queues a B2C payout to the buyer in the normal platform-admin payout queue, labelled "Buyer refund" and deducted from the organiser's balance. The booking fee isn't refunded. The refund's status follows its payout, and a failed one can be retried by the organiser.Dashboard → Promoters. Each promoter has a tracked link (/<org>?ref=<code> or /<org>/<event>?ref=<code>) and their own private stats page (/promoter/<token>). The ref is remembered on the buyer's device for 30 days, last click wins. Commission (a % of the sale, or KES per ticket) is fixed on each order when it's placed.
Commission owed to promoters is held back from the organiser's withdrawable balance. Paying a promoter goes through the same safeguards as the organiser's own payouts: an emailed confirmation code, then platform-admin approval, then M-Pesa B2C to the promoter's number. The promoter receives exactly the commission; the platform fee is added on top and comes out of the organiser's balance.
Tickets always go by email, which is the source of truth for ticket ownership and lookup. WhatsApp is an extra channel: tickets, lookup links, Event Space invites, reminders, Lipa Pole Pole updates, feedback requests and certificates. Every send goes through src/lib/whatsappSender.ts, which uses one of two providers, chosen by WHATSAPP_PROVIDER:
WHATSAPP_PROVIDER | What it is | Where it works |
|---|---|---|
baileys (default) | Unofficial WhatsApp Web link (Baileys). Connect it in /platform-admin → WhatsApp → scan the QR with the sending phone. The login is stored in MongoDB (WhatsappAuthKey), so it survives restarts and deploys. | One long-running server process (Render, Railway, a VPS). Not Vercel or other serverless hosts, which can't keep the connection open and run several copies at once. |
cloud | Meta's official WhatsApp Cloud API with pre-approved templates. | Anywhere, serverless included. |
Baileys is unofficial: WhatsApp can disconnect or ban the number without warning. The app reconnects after drops and restarts, but a real logout needs someone to re-scan the QR code.
The code is already in place; switching is configuration only. Every message has a template defined in src/lib/whatsappTemplates.json, eight in all, every one in the UTILITY category.
whatsapp_business_messaging and whatsapp_business_management permissions.WHATSAPP_CLOUD_TOKEN, WHATSAPP_BUSINESS_ACCOUNT_ID and META_APP_ID in your local .env and run:
node scripts/create-whatsapp-templates.mjs --dry-run # preview
node scripts/create-whatsapp-templates.mjs # submit
node scripts/create-whatsapp-templates.mjs --status # wait until all say APPROVED
WHATSAPP_CLOUD_TOKEN, WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_PROVIDER=cloud, then redeploy. The platform-admin WhatsApp tab shows "Cloud API".Notes:
<your URL>/api/tickets/qr/<code>. The app must be reachable on a public HTTPS URL.whatsappTemplates.json (positional {{1}}… placeholders, never the first or last thing in the body) and the matching builder in whatsappTemplates.ts, then submit again. A send fails loudly if its parameter count doesn't match the template.WHATSAPP_TEMPLATE_LANGUAGE (default en) must match the language the templates were approved in. WHATSAPP_GRAPH_VERSION defaults to v23.0.The app needs one long-running Node server. That's why it doesn't fully work on Vercel: Baileys can't run there, and background sends and the in-memory caches assume a single process. Render runs it as a normal server and gives you a public URL (https://<name>.onrender.com) for both the website and the API.
Create the service. Either New → Blueprint with this repo (it reads render.yaml: one free web service in Frankfurt, health checks on /api/health, random values for every secret), or New → Web Service by hand with build command npm ci && npm run build, start command npm start, and the variables from render.yaml. You fill in DATABASE_URL, the M-Pesa, Resend and Cloudinary keys, and the platform-admin login.
Plan. The free plan works. The WhatsApp login lives in MongoDB, so no disk is needed. A free instance sleeps after 15 idle minutes; the 5-minute cron jobs below keep it awake (750 free hours a month covers one service running all month), and after any restart the scheduler reconnects WhatsApp by itself. Starter never sleeps. Never run more than one instance.
MongoDB Atlas. Under Network Access, allow Render's outbound IPs (listed on the service's Connect tab), or 0.0.0.0/0.
Public URL. Leave NEXT_PUBLIC_APP_URL empty to use the onrender.com address automatically. M-Pesa callbacks, emailed links and QR codes all use it. Once you add a custom domain, set NEXT_PUBLIC_APP_URL to it and redeploy (NEXT_PUBLIC_* values are baked in at build time).
Cron. Two jobs, every 5 minutes, each a POST with header Authorization: Bearer <CRON_SECRET>:
https://<your-url>/api/cron/reconcilehttps://<your-url>/api/cron/scheduledEither use a free scheduler such as cron-job.org (it supports custom headers), or add a Render Cron Job running curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" https://<your-url>/api/cron/scheduled (and the same for reconcile).
Database. Run npx prisma db push against the production database after schema changes; this repo doesn't use migrations.
M-Pesa payouts (B2C/B2B). Commit Safaricom's public certificate as certs/sandbox_cert.cer or certs/production_cert.cer; see src/lib/mpesaB2C.ts for the download links. These are public certificates, safe to commit.
AT_USERNAME, AT_API_KEY and optionally AT_SENDER_ID set, tickets go by SMS (links to /t/<code>) when there's no WhatsApp or WhatsApp failed, and always for box-office sales. AT_USERNAME=sandbox uses the AT sandbox.WhatsApp. Open /platform-admin → WhatsApp → Connect, and scan the QR code once. The login is saved in MongoDB and survives restarts and deploys. Disconnecting from that tab (or logging the device out on the phone) deletes it.