Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ jobs:
run: node --test tests/integration/attendance-schema.test.mjs
- name: Validate B3 schema constraints and public revision safety on PostgreSQL 17
run: node --test tests/integration/b3-schema.test.mjs
- name: Validate B4 append-only payments, receipt uniqueness and tenant constraints on PG17
run: node --test tests/integration/b4-finance-schema.test.mjs
- name: One-time platform administrator provisioning on isolated PostgreSQL 17
run: node --test tests/integration/platform-bootstrap.test.mjs
- name: Legacy unversioned Better Auth baseline and populated upgrade
Expand Down
36 changes: 36 additions & 0 deletions docs/B4_CLUB_FINANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# B4 — Externally recorded club payments, dues and receipts

Product contract approved for implementation 12 November 2026. B4 is **club operations bookkeeping**, not payment processing, a gateway, statutory invoicing, or ClubKeep SaaS billing.

## Vocabulary and data ownership
- A **charge** is the existing immutable MemberSubscription term price snapshot, denominated in integer minor units with ISO currency. Its amount is an internal expected club fee, **not recognized revenue**. No second invoice/charge record is silently created on renewal.
- A **recorded collection** is staff's attestation that the club received money externally by CASH, UPI, CARD or OTHER. Never infer payment from assignment, check-in, QR, or membership expiry.
- **Outstanding dues** = max(0, term snapshot price - sum(nonreversed payment records allocated to that term)). No cross-term auto-credit, cross-currency netting or unallocated suspense in B4.
- Canceled membership terms are **excluded from actionable dues**, but original charges, payments and cancellations remain visible; cancellation never implies a refund or automatically deletes a payment.
- A **reversal** corrects a mistaken payment entry, NOT a payment gateway refund. One append-only reversal row per payment, with an owner/manager, reason, audit and timestamp. The original receipt is retained and explicitly marked VOID.
- Owner receipt numbers monotonically increment per club, never reuse after a reversal. Printable receipt is proof of an externally **staff-recorded payment**, not a compliant GST/tax invoice and not gateway confirmation.
- Receipt snapshots preserve club name, member name/number, membership term, currency and amount as at recording, despite later edits/renames.
- If any club has membership terms in more than one currency, dashboards and totals remain separated by currency. Don't present mixed money as one total.
- Funds never move through ClubKeep. No card PAN/CVV, bank credentials, UPI PIN, processing, disbursement, gateway webhooks, refunds, taxes, statutory invoices or ClubKeep subscription checkout.

## Behavior
- OWNER/MANAGER/STAFF may record a collection for an ACTIVE member and noncancelled term, up to its remaining balance. OWNER/MANAGER may reverse one mistaken entry; STAFF cannot reverse.
- New collection's receivedAt defaults to trusted server time. No backdating in B4. Include optional external reference and note; neither is a verification claim.
- Every mutation requires verified session, exact-origin CSRF, isolated Redis rate limiting, bounded request, Zod parsing, club role, tenant status/readiness and immutable append-only transaction.
- Idempotency keys are UUIDs; concurrent retries serialize and replay the same result only for exactly matching payload. Collision with a different payload returns 409. Zero fee terms cannot have positive payments.
- Payment flow uses a club-scoped row lock to serialize receipt numbering and term balance, plus composite foreign keys for tenant isolation. Database CHECKs enforce positive amounts, and append-only triggers protect historical rows.
- Receipt/ledger screens and member financial history are authenticated and \`no-store\`. Owner/manager dashboard and full ledger, staff member-attached recording and receipt view; no open public financial data. The user-facing Personal domain never appears in Business finance queries.
- Record list and dues list must page; never fetch all payments or terms in the browser.
- Free/Starter/Growth/Pro pre-approved caps remain unchanged. No Free monthly transaction quota value is invented. No automatic conversion of currency/amount.
- Use Luxe visual tokens and mobile 360/390px, tablet, desktop; provide accessible forms, pending/loading/errors, and honest empty states.

## Routes
- \`/clubs/[clubId]/finance\` (owner/manager collections, outstanding dues and financial trends)
- \`/clubs/[clubId]/finance/payments\` (owner/manager immutable ledger and audit reversals)
- \`/clubs/[clubId]/finance/dues\` (owner/manager pagination)
- \`/clubs/[clubId]/finance/receipts/[paymentId]\` (owner/manager/staff private printable receipt)
- \`POST /api/clubs/[clubId]/finance/payments\` and \`POST /api/clubs/[clubId]/finance/payments/[paymentId]/reverse\` (strict protected mutations)
- Existing member details include payment form, term balance, payment history and receipt link.

## Release gates
Fresh/populated/legacy/noop Prisma PG17 migration and schema drift; unique receipt/idempotency, composite FKs, server-side authorization, reversal append-only, concurrent double-submit, multi-currency amounts, cancellation and no Personal access. Existing B1/B2/B3 and native x64/ARM64 standalone CI, targeted visual accessibility and privacy checks, off-host restore and MenuMinute isolation. Merge/deploy separately from writing code.
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ ClubKeep is a polished, mobile-first operating system for small fitness and acti
- **Pitch:** Run your club from your phone. Keep your members for life.
- **North-star metric:** weekly active members per paying club.
- **Market:** global from day one. India uses INR pricing and Razorpay; the rest of the world uses USD pricing and Stripe.
- **First vertical:** gyms. Sports clubs, yoga and dance studios follow on the same engine with their own templates.
- **Launch verticals:** gym/fitness, yoga, dance, sports and general clubs from day one, with a shared membership engine (B3 owner decision on 11 November 2026). Advanced class packs, schedules, batches and competition engines follow separately.
- **Build:** solo developer, first release in about one month.

## Principles
Expand Down
15 changes: 10 additions & 5 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# ClubKeep roadmap

Last updated: 10 Nov 2026 (final medical Free and no-clinician clarifications). This remains the **Business gym-first** roadmap. ClubKeep Personal has a separate staged plan in [DELIVERY_PLAN.md](DELIVERY_PLAN.md). Round 1–2 decisions are locked in [BUSINESS_SPEC.md](BUSINESS_SPEC.md), with pricing and unresolved caps in [ENTITLEMENTS.md](ENTITLEMENTS.md). Agents pick work from the top unchecked item; do not infer unapproved limits or claim planned health features shipped.
Last updated: 12 Nov 2026 (B4 external fee ledger implementation). **All club verticals are available at launch**, as approved in B3. Existing advanced vertical-specific engines remain separately staged. ClubKeep Personal has a separate staged plan in [DELIVERY_PLAN.md](DELIVERY_PLAN.md). Round 1–2 decisions are locked in [BUSINESS_SPEC.md](BUSINESS_SPEC.md), with pricing and unresolved caps in [ENTITLEMENTS.md](ENTITLEMENTS.md). Agents pick work from the top unchecked item; do not infer unapproved limits or claim planned health features shipped.

## Phase 0 foundation first

- [x] **Phase 0A complete in GitHub:** PR #7 merged; Node 24/Prisma 7 config, native-service x64/ARM64 CI and removal of fake SMS OTP demo. No production deployment.
- [x] **Phase 0B merged:** PR #9 (commit `d8b6395b`) added shared identity schema, two Prisma migrations and native PG17/x64/ARM64 tests. **Schema code merged, not deployed to production.**
- [x] **Phase 0C code merged:** [real authentication plan](PHASE_0C_AUTHENTICATION_PLAN.md): Google OAuth with explicit linking, passkeys after verified login, hashed five-minute magic links via self-hosted `mx.bababuilds.com:587` STARTTLS, recovery and abuse controls. No SMS or production provisioning in the code PR.

- [ ] **Phase 0D in PR #15 (draft):** [club onboarding runbook](PHASE_0D_RUNBOOK.md): authenticated club creation, owner role, atomic seven-day invite issue/claim/revoke, local QR, tenant isolation; live provider and policy gates remain.
- [x] **Phase 0D merged in PR #15:** [club onboarding runbook](PHASE_0D_RUNBOOK.md): authenticated club creation, owner role, atomic seven-day invite issue/claim/revoke, local QR, tenant isolation; live provider and policy gates remain.
- [ ] [SMS-free production foundation](PHASE_0_FOUNDATION.md): Google OAuth, passkeys, single-use 7-day invites/QR, email-link recovery, optional nonunique unverified phone contacts, shared global identity, Prisma 7 migrations, club-scoped roles, isolated Redis security, native co-host deployment, GitHub CI and restore tests.
- [ ] **No Phase 0 medical tables**; Free medical vault (insurance information, blood type, user-authored biological instructions) and paid clinical history are later Personal features.
- [ ] No dedicated clinician accounts. Verified two-way external messaging is deferred pending a separately approved non-account model.
Expand All @@ -20,13 +20,18 @@ Last updated: 10 Nov 2026 (final medical Free and no-clinician clarifications).
| 1 | Nov 2026 | Discover and design: interviews, scope lock | 10 gym-owner interviews done |
| 2 | Dec 2026 | One-month solo build of the month-1 scope, 5 partner gyms live | 5 partner gyms using it every week |
| 3 | Jan to Mar 2027 | Paid launch, pricing tiers live, WhatsApp AI beta | 20 paying clubs, churn under 3% a month |
| 4 | Apr to Sep 2027 | Scale: yoga, dance and sports templates, city leaderboards | Growth toward 100 clubs |
| 4 | Apr to Sep 2027 | Scale: advanced yoga, dance and sports capabilities, city leaderboards | Growth toward 100 clubs |

## B1 implementation status

- [x] **B1 Members & Membership Plans, merged PR #16:** additive roster and plan schema, club-specific walk-in registration and optional unverified contact, plan CRUD, idempotent term assignment/renewal, central active member limits, mobile-first Luxe UI, native PostgreSQL tests. Full production release requires green CI, verified staging provider and restore gates. See [B1 runbook](B1_MEMBERS_PLANS_RUNBOOK.md).

- [ ] **B2 Attendance & Check-in, PR in review:** signed short-lived member QR, manual staff check-in, indexed local-day attendance ledger, member pass, history/CSV and Luxe reception UI. [B2 runbook](B2_ATTENDANCE_RUNBOOK.md). Live staging acceptance and policy limits remain.
- [x] **B2 Attendance & Check-in, merged PR #17:** signed short-lived member QR, manual staff check-in, indexed local-day attendance ledger, member pass, history/CSV and Luxe reception UI. [B2 runbook](B2_ATTENDANCE_RUNBOOK.md). Live staging acceptance and policy limits remain.

## B3 / B4 implementation status

- [x] **B3 Unified Club Experience, merged PR #20:** all five verticals, required setup for new clubs, Luxe navigation and opt-in, moderated public club profiles.
- [ ] **B4 Finance and Receipts, PR #21:** externally received collections, member dues, printable immutable payment snapshots and owner financial views. See [B4 release contract](B4_CLUB_FINANCE.md). This is not gateway processing or statutory invoicing. CI and staging gates apply.

## Month-1 build order

Expand All @@ -36,7 +41,7 @@ Cut from the bottom of this list first if time runs short (order of cuts: ID car
2. [ ] **Clubs and roles:** `Club`, `ClubMember`, role guards, create-a-club flow with vertical template and branding.
3. [x] **Members and plans:** `Plan`, `Membership`, invite members via secure single-use links or QR (`Invite`), optional unverified phone contacts, member list and renewal status.
4. [ ] **Check-in:** QR (signed rotating token), manual by staff, attendance history.
5. [ ] **Payment records:** record cash, UPI or card payments, dues, receipts.
5. [ ] **Payment records (B4 PR #21 in review):** externally received cash, UPI or card records, dues, receipts, corrections. No payment gateway settlement.
6. [ ] **Member app:** profile, bio stats history, payment history, my clubs.
7. [ ] **ID card:** digital card page with QR to the member's profile, printable at credit-card size with one default layout.
8. [ ] **Leaderboards:** `Season`, `Activity`, `Kpi`, `Score` with approval, club leaderboard, shareable visual.
Expand Down
79 changes: 79 additions & 0 deletions prisma/migrations/20261112010000_b4_club_finance/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
-- B4 ledger: additive, no changes to membership terms or existing prices.
ALTER TYPE "SecurityEventType" ADD VALUE IF NOT EXISTS 'PAYMENT_RECORDED';
ALTER TYPE "SecurityEventType" ADD VALUE IF NOT EXISTS 'PAYMENT_REVERSED';
CREATE TYPE "ClubPaymentMethod" AS ENUM ('CASH','UPI','CARD','OTHER');

ALTER TABLE "Club" ADD COLUMN "receiptSequence" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Club" ADD CONSTRAINT "Club_receiptSequence_check" CHECK ("receiptSequence" >= 0);
CREATE UNIQUE INDEX "MemberSubscription_clubId_id_key" ON "MemberSubscription"("clubId","id");

CREATE TABLE "ClubPayment" (
"id" TEXT NOT NULL,
"clubId" TEXT NOT NULL,
"memberRecordId" TEXT NOT NULL,
"subscriptionId" TEXT NOT NULL,
"receiptNo" INTEGER NOT NULL,
"operationKey" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" VARCHAR(3) NOT NULL,
"method" "ClubPaymentMethod" NOT NULL,
"externalReference" VARCHAR(120),
"note" VARCHAR(240),
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"recordedByUserId" TEXT NOT NULL,
"clubName" VARCHAR(200) NOT NULL,
"memberName" VARCHAR(100) NOT NULL,
"memberNumber" INTEGER NOT NULL,
"planName" VARCHAR(90) NOT NULL,
"termPriceMinor" INTEGER NOT NULL,
"termStartsAt" TIMESTAMP(3) NOT NULL,
"termEndsAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ClubPayment_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ClubPayment_amount_check" CHECK ("amountMinor" > 0 AND "amountMinor" <= 100000000),
CONSTRAINT "ClubPayment_receipt_check" CHECK ("receiptNo" > 0),
CONSTRAINT "ClubPayment_termPrice_check" CHECK ("termPriceMinor" >= 0),
CONSTRAINT "ClubPayment_currency_check" CHECK ("currency" ~ '^[A-Z]{3}$'),
CONSTRAINT "ClubPayment_memberNumber_check" CHECK ("memberNumber" >= 0),
CONSTRAINT "ClubPayment_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ClubPayment_clubId_memberRecordId_fkey" FOREIGN KEY ("clubId","memberRecordId") REFERENCES "ClubMemberRecord"("clubId","id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ClubPayment_clubId_subscriptionId_fkey" FOREIGN KEY ("clubId","subscriptionId") REFERENCES "MemberSubscription"("clubId","id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ClubPayment_recordedByUserId_fkey" FOREIGN KEY ("recordedByUserId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "ClubPayment_operationKey_key" ON "ClubPayment"("operationKey");
CREATE UNIQUE INDEX "ClubPayment_clubId_id_key" ON "ClubPayment"("clubId","id");
CREATE UNIQUE INDEX "ClubPayment_clubId_receiptNo_key" ON "ClubPayment"("clubId","receiptNo");
CREATE INDEX "ClubPayment_clubId_receivedAt_id_idx" ON "ClubPayment"("clubId","receivedAt" DESC,"id" DESC);
CREATE INDEX "ClubPayment_clubId_memberRecordId_createdAt_idx" ON "ClubPayment"("clubId","memberRecordId","createdAt" DESC);
CREATE INDEX "ClubPayment_clubId_subscriptionId_idx" ON "ClubPayment"("clubId","subscriptionId");

CREATE TABLE "ClubPaymentReversal" (
"id" TEXT NOT NULL,
"clubId" TEXT NOT NULL,
"paymentId" TEXT NOT NULL,
"reason" VARCHAR(240) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reversedByUserId" TEXT NOT NULL,
CONSTRAINT "ClubPaymentReversal_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ClubPaymentReversal_reason_check" CHECK (char_length(btrim("reason")) BETWEEN 8 AND 240),
CONSTRAINT "ClubPaymentReversal_clubId_fkey" FOREIGN KEY ("clubId") REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ClubPaymentReversal_clubId_paymentId_fkey" FOREIGN KEY ("clubId","paymentId") REFERENCES "ClubPayment"("clubId","id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ClubPaymentReversal_reversedByUserId_fkey" FOREIGN KEY ("reversedByUserId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "ClubPaymentReversal_paymentId_key" ON "ClubPaymentReversal"("paymentId");
CREATE UNIQUE INDEX "ClubPaymentReversal_clubId_paymentId_key" ON "ClubPaymentReversal"("clubId","paymentId");
CREATE INDEX "ClubPaymentReversal_clubId_createdAt_idx" ON "ClubPaymentReversal"("clubId","createdAt" DESC);

-- A reversal is the only correction path; financial entries cannot be edited or deleted.
CREATE FUNCTION clubkeep_finance_append_only() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'ClubKeep finance ledger is append-only; create a reversal instead';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "ClubPayment_immutable"
BEFORE UPDATE OR DELETE ON "ClubPayment"
FOR EACH ROW EXECUTE FUNCTION clubkeep_finance_append_only();
CREATE TRIGGER "ClubPaymentReversal_immutable"
BEFORE UPDATE OR DELETE ON "ClubPaymentReversal"
FOR EACH ROW EXECUTE FUNCTION clubkeep_finance_append_only();
Loading
Loading