diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5f03f2..563f127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/B4_CLUB_FINANCE.md b/docs/B4_CLUB_FINANCE.md new file mode 100644 index 0000000..0fab7d1 --- /dev/null +++ b/docs/B4_CLUB_FINANCE.md @@ -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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 213db70..f5d9679 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad56a7d..3adb4f0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # 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 @@ -8,7 +8,7 @@ Last updated: 10 Nov 2026 (final medical Free and no-clinician clarifications). - [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. @@ -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 @@ -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. diff --git a/prisma/migrations/20261112010000_b4_club_finance/migration.sql b/prisma/migrations/20261112010000_b4_club_finance/migration.sql new file mode 100644 index 0000000..1cd4c3d --- /dev/null +++ b/prisma/migrations/20261112010000_b4_club_finance/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a17c5d3..5e9292e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,8 @@ model User { securityEvents SecurityEvent[] @relation("SecurityEventActor") memberEvents MemberEvent[] recordedAttendance ClubAttendance[] @relation("AttendanceRecorder") + recordedClubPayments ClubPayment[] @relation("ClubPaymentRecorder") + reversedClubPayments ClubPaymentReversal[] @relation("ClubPaymentReversalActor") @@map("user") } @@ -160,6 +162,8 @@ enum SecurityEventType { MEMBERSHIP_CHANGED SECURITY_REVIEW CLUB_TIMEZONE_CHANGED + PAYMENT_RECORDED + PAYMENT_REVERSED } model Club { @@ -172,6 +176,7 @@ model Club { // Provisional tenant entitlements; no public tier-switching or checkout in B1. businessTier BusinessTier @default(FREE) memberSequence Int @default(0) + receiptSequence Int @default(0) // IANA zone for the club's calendar-day attendance boundary; UTC is safe until configured. timeZone String @default("UTC") vertical ClubVertical @default(OTHER) @@ -190,6 +195,8 @@ model Club { subscriptions MemberSubscription[] memberEvents MemberEvent[] attendanceEvents ClubAttendance[] + payments ClubPayment[] + paymentReversals ClubPaymentReversal[] } model ClubMembership { @@ -320,6 +327,7 @@ model ClubMemberRecord { subscriptions MemberSubscription[] events MemberEvent[] attendanceEvents ClubAttendance[] + payments ClubPayment[] @@unique([clubId, id]) @@unique([clubId, memberNumber]) @@ -365,7 +373,9 @@ model MemberSubscription { club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) memberRecord ClubMemberRecord @relation(fields: [clubId, memberRecordId], references: [clubId, id], onDelete: Restrict) plan ClubPlan @relation(fields: [clubId, planId], references: [clubId, id], onDelete: Restrict) + payments ClubPayment[] + @@unique([clubId, id]) @@index([clubId, memberRecordId, startAt, endAt]) @@index([clubId, endAt]) } @@ -484,3 +494,69 @@ model ClubPublicRevision { @@index([clubId, status, submittedAt]) @@index([status, submittedAt]) } + + +enum ClubPaymentMethod { + CASH + UPI + CARD + OTHER +} + +/** + * Immutable attestation of an external fee collection. NOT a gateway transaction + * or tax invoice. The receiptNo is sequential and never reused per club. + */ +model ClubPayment { + id String @id @default(cuid()) + clubId String + memberRecordId String + subscriptionId String + receiptNo Int + operationKey String @unique + amountMinor Int + currency String @db.VarChar(3) + method ClubPaymentMethod + externalReference String? @db.VarChar(120) + note String? @db.VarChar(240) + receivedAt DateTime @default(now()) + createdAt DateTime @default(now()) + recordedByUserId String + + // Immutable receipt snapshots: no private Personal information, never PAN/UPI credentials. + clubName String @db.VarChar(200) + memberName String @db.VarChar(100) + memberNumber Int + planName String @db.VarChar(90) + termPriceMinor Int + termStartsAt DateTime + termEndsAt DateTime + + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) + member ClubMemberRecord @relation(fields: [clubId, memberRecordId], references: [clubId, id], onDelete: Restrict) + subscription MemberSubscription @relation(fields: [clubId, subscriptionId], references: [clubId, id], onDelete: Restrict) + recorder User @relation("ClubPaymentRecorder", fields: [recordedByUserId], references: [id], onDelete: Restrict) + reversal ClubPaymentReversal? + + @@unique([clubId, id]) + @@unique([clubId, receiptNo]) + @@index([clubId, receivedAt(sort: Desc), id(sort: Desc)]) + @@index([clubId, memberRecordId, createdAt(sort: Desc)]) + @@index([clubId, subscriptionId]) +} + +model ClubPaymentReversal { + id String @id @default(cuid()) + clubId String + paymentId String @unique + reason String @db.VarChar(240) + createdAt DateTime @default(now()) + reversedByUserId String + + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) + payment ClubPayment @relation(fields: [clubId, paymentId], references: [clubId, id], onDelete: Restrict) + actor User @relation("ClubPaymentReversalActor", fields: [reversedByUserId], references: [id], onDelete: Restrict) + + @@unique([clubId, paymentId]) + @@index([clubId, createdAt(sort: Desc)]) +} diff --git a/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts b/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts new file mode 100644 index 0000000..d9255c9 --- /dev/null +++ b/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { reverseClubPayment } from "@/lib/finance-service"; +import { reverseClubPaymentInput } from "@/lib/validations/finance"; +import { idInput } from "@/lib/validations/onboarding"; +import { + guardOnboardingPost, + jsonPrivate, + onboardingFailure, + readOnboardingBody, +} from "@/lib/onboarding-http"; + +export async function POST( + request: NextRequest, + { + params, + }: { + params: Promise<{ clubId: string; paymentId: string }>; + } +): Promise { + try { + const { clubId, paymentId } = await params; + if (!idInput.safeParse(clubId).success || !idInput.safeParse(paymentId).success) + return jsonPrivate({ error: "Payment not found" }, 404); + const guard = await guardOnboardingPost(request, "b4-reverse-payment", 12, true); + if (!guard.ok) return guard.response; + const input = reverseClubPaymentInput.safeParse(await readOnboardingBody(request)); + if (!input.success) return jsonPrivate({ error: "Reason must contain 8–240 characters" }, 400); + const reversal = await reverseClubPayment( + prisma, + guard.userId!, + clubId, + paymentId, + input.data.reason + ); + return jsonPrivate({ reversal }, reversal.repeated ? 200 : 201); + } catch (error) { + return onboardingFailure(error); + } +} diff --git a/src/app/api/clubs/[clubId]/finance/payments/route.ts b/src/app/api/clubs/[clubId]/finance/payments/route.ts new file mode 100644 index 0000000..8eb9ce7 --- /dev/null +++ b/src/app/api/clubs/[clubId]/finance/payments/route.ts @@ -0,0 +1,58 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireClubActive } from "@/lib/member-service"; +import { recordClubPayment, recentClubPayments } from "@/lib/finance-service"; +import { recordClubPaymentInput } from "@/lib/validations/finance"; +import { idInput } from "@/lib/validations/onboarding"; +import { + guardOnboardingPost, + jsonPrivate, + onboardingFailure, + readOnboardingBody, +} from "@/lib/onboarding-http"; + +type Context = { params: Promise<{ clubId: string }> }; +export async function POST(request: NextRequest, { params }: Context): Promise { + try { + const { clubId } = await params; + if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Club not found" }, 404); + const guard = await guardOnboardingPost(request, "b4-record-payment", 45, true); + if (!guard.ok) return guard.response; + const input = recordClubPaymentInput.safeParse(await readOnboardingBody(request)); + if (!input.success) + return jsonPrivate({ error: "Check payment amount, method and membership" }, 400); + const payment = await recordClubPayment(prisma, guard.userId!, clubId, input.data); + return jsonPrivate( + { + payment, + receiptUrl: + "/clubs/" + + encodeURIComponent(clubId) + + "/finance/receipts/" + + encodeURIComponent(payment.id), + }, + payment.repeated ? 200 : 201 + ); + } catch (error) { + return onboardingFailure(error); + } +} +export async function GET(request: NextRequest, { params }: Context): Promise { + try { + const { clubId } = await params; + if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Club not found" }, 404); + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user?.emailVerified) + return jsonPrivate({ error: "Sign in and verify your account" }, 401); + await requireClubActive(prisma, session.user.id, clubId, ["OWNER", "MANAGER"]); + const cursor = request.nextUrl.searchParams.get("cursor") ?? undefined; + if (cursor && !idInput.safeParse(cursor).success) + return jsonPrivate({ error: "Invalid cursor" }, 400); + const rows = await recentClubPayments(prisma, clubId, cursor); + const page = rows.slice(0, 50); + return jsonPrivate({ payments: page, nextCursor: rows.length > 50 ? page.at(-1)?.id : null }); + } catch (error) { + return onboardingFailure(error); + } +} diff --git a/src/app/clubs/[clubId]/finance/dues/page.tsx b/src/app/clubs/[clubId]/finance/dues/page.tsx new file mode 100644 index 0000000..d03eb17 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/dues/page.tsx @@ -0,0 +1,100 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { CalendarClock, ArrowRight } from "lucide-react"; +import type { Metadata } from "next"; +import { requireSession } from "@/lib/required-session"; +import { requireClubActive } from "@/lib/member-service"; +import { prisma } from "@/lib/prisma"; +import { clubDuesPage } from "@/lib/finance-service"; +import { formatMinorMoney } from "@/lib/money-format"; +import { BusinessShell } from "@/components/clubs/business-shell"; +import { idInput } from "@/lib/validations/onboarding"; +export const metadata: Metadata = { + title: "Outstanding membership dues · ClubKeep", + robots: { index: false }, +}; +type Props = { params: Promise<{ clubId: string }>; searchParams: Promise<{ cursor?: string }> }; +export default function Dues(props: Props) { + return ( + Loading dues…} + > + + + ); +} +async function DuesContent({ params, searchParams }: Props) { + const { clubId } = await params, + { cursor } = await searchParams, + session = await requireSession(); + await requireClubActive(prisma, session.user.id, clubId, ["OWNER", "MANAGER"]); + const club = await prisma.club.findUniqueOrThrow({ + where: { id: clubId }, + select: { name: true }, + }); + const safeCursor = cursor && idInput.safeParse(cursor).success ? cursor : undefined; + const records = await clubDuesPage(prisma, clubId, safeCursor), + page = records.slice(0, 50); + return ( + +
+ {page.length === 0 ? ( +
+ +

No outstanding dues on this page

+

+ You are all caught up, or there are no memberships assigned. +

+
+ ) : ( +
+ {page.map((r) => ( + +
+

+ {r.memberName} #{r.memberNumber} +

+

+ {r.planName} · Expires {r.endAt.toISOString().slice(0, 10)} +

+
+
+ + + {formatMinorMoney(r.dueMinor, r.currency)} + + Remaining + + +
+ + ))} +
+ )} +
+ {records.length > 50 && page.at(-1) && ( + + + )} +

+ Open a member to record an externally received payment. Financial figures are not + bank-verified. +

+
+ ); +} diff --git a/src/app/clubs/[clubId]/finance/page.tsx b/src/app/clubs/[clubId]/finance/page.tsx new file mode 100644 index 0000000..0e30a36 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/page.tsx @@ -0,0 +1,209 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { + WalletCards, + Coins, + CalendarClock, + ReceiptText, + ArrowUpRight, + History, + ArrowRight, +} from "lucide-react"; +import type { Metadata } from "next"; +import { requireSession } from "@/lib/required-session"; +import { requireClubActive } from "@/lib/member-service"; +import { prisma } from "@/lib/prisma"; +import { + clubFinanceSummary, + recentClubPayments, + financeMonthlyTrend, + receiptNumber, +} from "@/lib/finance-service"; +import { formatMinorMoney } from "@/lib/money-format"; +import { BusinessShell } from "@/components/clubs/business-shell"; + +export const metadata: Metadata = { title: "Club finance · ClubKeep", robots: { index: false } }; +type Props = { params: Promise<{ clubId: string }> }; +export default function ClubFinance(props: Props) { + return ( + Loading club finances… + } + > + + + ); +} +async function FinanceContent({ params }: Props) { + const { clubId } = await params, + session = await requireSession(); + await requireClubActive(prisma, session.user.id, clubId, ["OWNER", "MANAGER"]); + const club = await prisma.club.findUniqueOrThrow({ + where: { id: clubId }, + select: { name: true, timeZone: true }, + }); + const [totals, recent, trend] = await Promise.all([ + clubFinanceSummary(prisma, clubId), + recentClubPayments(prisma, clubId), + financeMonthlyTrend(prisma, clubId, club.timeZone), + ]); + const base = "/clubs/" + encodeURIComponent(clubId) + "/finance"; + return ( + +
+ + + View outstanding dues + + + + All recorded payments + +
+
+ {totals.length === 0 ? ( +
+ +

A clearer picture starts here

+

+ Create a membership plan and assign your first member. Record externally received fees + from the member profile. +

+ + Go to members + +
+ ) : ( + totals.map((entry) => ( +
+
+
+

+ {entry.currency} · Club finances +

+

+ {entry.terms} active or scheduled membership terms +

+
+ + Currency totals kept separate + +
+
+ {[ + { icon: Coins, label: "Expected term fees", value: entry.charged }, + { icon: WalletCards, label: "Recorded net collections", value: entry.collected }, + { icon: ReceiptText, label: "Outstanding dues", value: entry.outstanding }, + { icon: CalendarClock, label: "Overdue at term expiry", value: entry.overdue }, + ].map(({ icon: Icon, label, value }) => ( +
+ +

+ {formatMinorMoney(value, entry.currency)} +

+

{label}

+
+ ))} +
+
+ )) + )} +
+
+

Collections over time

+

+ Monthly externally recorded net collections, local club time +

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

+ Once you record your first fee, monthly totals will appear here. +

+ ) : ( +
+ {trend.slice(-18).map((t) => ( +
+ + {t.month} · {t.currency} + + + {formatMinorMoney(t.amount, t.currency)} + +
+ ))} +
+ )} +
+
+
+

Recent entries

+ + View all + +
+ {recent.length === 0 ? ( +

+ No club payments have been recorded yet. +

+ ) : ( +
    + {recent.slice(0, 8).map((p) => ( +
  • + + + {receiptNumber(p.receiptNo)} + + + {p.memberName} · {p.method} + + + + {formatMinorMoney(p.amountMinor, p.currency)} + +
  • + ))} +
+ )} +
+
+

+ Financial records are owner/staff-entered attestations only. A canceled term has no + actionable dues; voided payments remain in the audit ledger. Statutory tax invoicing and + payment gateway verification are not provided. +

+
+
+ ); +} diff --git a/src/app/clubs/[clubId]/finance/payments/page.tsx b/src/app/clubs/[clubId]/finance/payments/page.tsx new file mode 100644 index 0000000..3bd6c0c --- /dev/null +++ b/src/app/clubs/[clubId]/finance/payments/page.tsx @@ -0,0 +1,105 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { History, ArrowRight } from "lucide-react"; +import type { Metadata } from "next"; +import { requireSession } from "@/lib/required-session"; +import { requireClubActive } from "@/lib/member-service"; +import { prisma } from "@/lib/prisma"; +import { recentClubPayments, receiptNumber } from "@/lib/finance-service"; +import { formatMinorMoney } from "@/lib/money-format"; +import { BusinessShell } from "@/components/clubs/business-shell"; +import { ReversePaymentButton } from "@/components/clubs/reverse-payment-button"; +import { idInput } from "@/lib/validations/onboarding"; +export const metadata: Metadata = { title: "Payment ledger · ClubKeep", robots: { index: false } }; +type Props = { params: Promise<{ clubId: string }>; searchParams: Promise<{ cursor?: string }> }; +export default function Ledger(props: Props) { + return ( + Loading ledger…} + > + + + ); +} +async function LedgerContent({ params, searchParams }: Props) { + const { clubId } = await params, + { cursor } = await searchParams, + session = await requireSession(); + await requireClubActive(prisma, session.user.id, clubId, ["OWNER", "MANAGER"]); + const club = await prisma.club.findUniqueOrThrow({ + where: { id: clubId }, + select: { name: true }, + }); + const safeCursor = cursor && idInput.safeParse(cursor).success ? cursor : undefined; + const rows = await recentClubPayments(prisma, clubId, safeCursor), + page = rows.slice(0, 50); + return ( + +
+ {page.length === 0 ? ( +
+ +

Your payment ledger is empty

+

+ Open a member profile to record a first collection. +

+
+ ) : ( +
+ {page.map((p) => ( +
+
+ + {receiptNumber(p.receiptNo)} + +

+ {p.memberName} · {p.planName} +

+

+ {p.method} · {p.receivedAt.toISOString().slice(0, 16).replace("T", " ")} UTC +

+
+
+ + {formatMinorMoney(p.amountMinor, p.currency)} + + {p.reversal ? ( + + VOID + + ) : ( + + )} +
+
+ ))} +
+ )} +
+ {rows.length > 50 && page.at(-1) && ( + + + )} +
+ ); +} diff --git a/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx b/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx new file mode 100644 index 0000000..3a92e10 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx @@ -0,0 +1,204 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { ArrowLeft, ShieldCheck, ReceiptText, AlertTriangle } from "lucide-react"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/required-session"; +import { requireClubActive } from "@/lib/member-service"; +import { formatMinorMoney } from "@/lib/money-format"; +import { receiptNumber } from "@/lib/finance-service"; +import { PrintClubReceiptButton } from "@/components/clubs/print-club-receipt-button"; +export const metadata: Metadata = { + title: "Recorded fee receipt · ClubKeep", + robots: { index: false }, +}; +type Props = { params: Promise<{ clubId: string; paymentId: string }> }; +export default function RecordedReceipt(props: Props) { + return ( + + Preparing your receipt… + + } + > + + + ); +} +async function ReceiptContent({ params }: Props) { + const { clubId, paymentId } = await params, + session = await requireSession(); + await requireClubActive(prisma, session.user.id, clubId, ["OWNER", "MANAGER", "STAFF"]); + const payment = await prisma.clubPayment.findFirst({ + where: { id: paymentId, clubId }, + select: { + id: true, + receiptNo: true, + clubName: true, + memberName: true, + memberNumber: true, + planName: true, + termPriceMinor: true, + termStartsAt: true, + termEndsAt: true, + currency: true, + amountMinor: true, + method: true, + externalReference: true, + note: true, + receivedAt: true, + createdAt: true, + recorder: { select: { name: true } }, + reversal: { select: { reason: true, createdAt: true, actor: { select: { name: true } } } }, + }, + }); + if (!payment) notFound(); + const voided = Boolean(payment.reversal); + return ( +
+
+
+ + + Payment ledger + + +
+
+
+
+

+ ClubKeep · Club receipt +

+

{payment.clubName}

+

+ Recorded external membership-fee collection +

+
+
+ CK +
+
+
+
+
+

+ Receipt number +

+

+ {receiptNumber(payment.receiptNo)} +

+

+ Recorded {payment.createdAt.toISOString().slice(0, 16).replace("T", " ")} UTC +

+
+
+

+ Status +

+

+ {voided ? "VOID / Reversed" : "RECORDED"} +

+

+ {voided + ? "This entry no longer counts toward dues" + : "Reported by club staff, not bank verified"} +

+
+
+
+
+

+ Received from +

+

{payment.memberName}

+

+ Club member #{String(payment.memberNumber).padStart(4, "0")} +

+
+
+

+ Membership +

+

{payment.planName}

+

+ {payment.termStartsAt.toISOString().slice(0, 10)} —{" "} + {payment.termEndsAt.toISOString().slice(0, 10)} (end exclusive) +

+
+
+
+
+ Membership term fee + + {formatMinorMoney(payment.termPriceMinor, payment.currency)} + +
+
+ Externally received via + {payment.method} +
+
+ This recorded payment + + {formatMinorMoney(payment.amountMinor, payment.currency)} + +
+
+ {payment.externalReference && ( +

+ External reference (staff-entered): {payment.externalReference} +

+ )} + {payment.note && ( +

Internal note: {payment.note}

+ )} +

+ Recorded by: {payment.recorder.name} · Received{" "} + {payment.receivedAt.toISOString().slice(0, 16).replace("T", " ")} UTC +

+ {payment.reversal && ( +
+

+ + This recorded payment was voided +

+

Reason: {payment.reversal.reason}

+

+ Reversed by {payment.reversal.actor.name} ·{" "} + {payment.reversal.createdAt.toISOString().slice(0, 16).replace("T", " ")} UTC +

+
+ )} +
+ + + This document confirms only that ClubKeep staff recorded a collection said to have + been received outside the app. ClubKeep is not the payment processor. It is not a + statutory tax invoice, bank confirmation or evidence of gateway settlement. + +
+
+
+

+ + Financial receipts are accessible only to authorized club personnel. +

+
+
+ ); +} diff --git a/src/app/clubs/[clubId]/members/[memberId]/page.tsx b/src/app/clubs/[clubId]/members/[memberId]/page.tsx index f88e045..ead0b46 100644 --- a/src/app/clubs/[clubId]/members/[memberId]/page.tsx +++ b/src/app/clubs/[clubId]/members/[memberId]/page.tsx @@ -9,6 +9,9 @@ import { prisma } from "@/lib/prisma"; import { statusForTerm } from "@/lib/membership-dates"; import { formatMinorMoney } from "@/lib/money-format"; import { BusinessShell } from "@/components/clubs/business-shell"; +import { memberClubFinance } from "@/lib/finance-service"; +import { RecordClubPaymentForm } from "@/components/clubs/record-club-payment-form"; +import { ReceiptText } from "lucide-react"; import { MemberActions } from "@/components/clubs/member-actions"; import { CancelTermButton } from "@/components/clubs/cancel-term-button"; @@ -70,6 +73,7 @@ async function MemberContent({ params }: Props) { }), ]); if (!member) notFound(); + const financialTerms = await memberClubFinance(prisma, clubId, member.id); const recentVisits = await prisma.clubAttendance.findMany({ where: { clubId, memberRecordId: member.id }, select: { id: true, localDate: true, method: true }, @@ -185,6 +189,69 @@ async function MemberContent({ params }: Props) { name: candidate.user.name, }))} /> + !t.cancelledAt && t.dueMinor > 0) + .map((t) => ({ + id: t.id, + planName: t.planName, + currency: t.currency, + dueMinor: t.dueMinor, + }))} + /> +
+

+ Membership dues & receipts +

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

No membership term or fee history yet.

+ ) : ( +
+ {financialTerms.slice(0, 10).map((term) => ( +
+
+
+

{term.planName}

+

+ {term.cancelledAt ? "Canceled term" : "Membership term"} · {term.currency} +

+
+
+

+ {formatMinorMoney(term.dueMinor, term.currency)} due +

+

+ {formatMinorMoney(term.paidMinor, term.currency)} recorded paid +

+
+
+ {term.payments.slice(0, 4).map((p) => ( +
+ + {formatMinorMoney(p.amountMinor, term.currency)} · {p.method}{" "} + {p.reversal ? "· VOID" : ""} + + + Receipt #{p.receiptNo} + +
+ ))} +
+ ))} +
+ )} +

diff --git a/src/components/clubs/business-shell.tsx b/src/components/clubs/business-shell.tsx index 186e683..6ac4afb 100644 --- a/src/components/clubs/business-shell.tsx +++ b/src/components/clubs/business-shell.tsx @@ -11,13 +11,14 @@ import { Building2, Menu, Fingerprint, + WalletCards, } from "lucide-react"; import { requireSession } from "@/lib/required-session"; import { prisma } from "@/lib/prisma"; import { requireActiveClubMembership } from "@/lib/club-access"; import { ClubThemeToggle } from "@/components/clubs/theme-toggle"; -type Active = "members" | "plans" | "renewals" | "overview" | "attendance"; +type Active = "members" | "plans" | "renewals" | "overview" | "attendance" | "finance"; export async function BusinessShell({ clubId, clubName, @@ -44,6 +45,9 @@ export async function BusinessShell({ { key: "members", label: "Members", href: "/members", icon: UsersRound }, { key: "plans", label: "Membership Plans", href: "/plans", icon: Layers3 }, { key: "renewals", label: "Renewals", href: "/renewals", icon: CalendarClock }, + ...(context.role === "OWNER" || context.role === "MANAGER" + ? [{ key: "finance", label: "Finance & dues", href: "/finance", icon: WalletCards }] + : []), ...(isOwner ? [ { @@ -153,7 +157,11 @@ export async function BusinessShell({ className="fixed inset-x-3 bottom-3 z-40 flex items-stretch justify-around rounded-2xl border border-[#d9ac73]/25 bg-[#18110b]/95 px-1 py-2 shadow-2xl backdrop-blur-xl lg:hidden" > {nav - .filter((x) => ["overview", "attendance", "members", "renewals"].includes(x.key)) + .filter( + (x) => + ["overview", "attendance", "members", "finance"].includes(x.key) || + (context.role === "STAFF" && x.key === "renewals") + ) .map(({ key, label, href, icon: Icon }) => ( ))} diff --git a/src/components/clubs/print-club-receipt-button.tsx b/src/components/clubs/print-club-receipt-button.tsx new file mode 100644 index 0000000..9a13650 --- /dev/null +++ b/src/components/clubs/print-club-receipt-button.tsx @@ -0,0 +1,14 @@ +"use client"; +import { Printer } from "lucide-react"; +export function PrintClubReceiptButton() { + return ( + + ); +} diff --git a/src/components/clubs/record-club-payment-form.tsx b/src/components/clubs/record-club-payment-form.tsx new file mode 100644 index 0000000..549592d --- /dev/null +++ b/src/components/clubs/record-club-payment-form.tsx @@ -0,0 +1,210 @@ +"use client"; +import { useRef, useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Wallet, ShieldCheck, ReceiptText } from "lucide-react"; +import { parseMinorMoney, formatMinorMoney } from "@/lib/money-format"; +type Term = { id: string; planName: string; currency: string; dueMinor: number }; +export function RecordClubPaymentForm({ + clubId, + memberId, + terms, +}: { + clubId: string; + memberId: string; + terms: Term[]; +}) { + const router = useRouter(), + idempotency = useRef(null); + const payable = terms.filter((t) => t.dueMinor > 0); + const [selected, setSelected] = useState(payable[0]?.id ?? ""); + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("CASH"); + const [reference, setReference] = useState(""), + [note, setNote] = useState(""); + const [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const [receipt, setReceipt] = useState(""); + const term = payable.find((t) => t.id === selected); + const field = + "min-h-11 w-full rounded-xl border border-[#f6dc9b]/20 bg-[#110b08] px-4 text-[#fff0dd] outline-offset-2 focus-visible:outline-2 focus-visible:outline-orange-400"; + async function submit(e: FormEvent) { + e.preventDefault(); + if (busy || !term) return; + setBusy(true); + setError(""); + setReceipt(""); + try { + let minor: number | null; + try { + minor = parseMinorMoney(amount.trim(), term.currency); + } catch { + minor = null; + } + if (minor === null || minor <= 0 || minor > term.dueMinor) + throw new Error("Enter a valid amount no greater than outstanding dues"); + if (!idempotency.current) idempotency.current = crypto.randomUUID(); + const result = await fetch("/api/clubs/" + encodeURIComponent(clubId) + "/finance/payments", { + method: "POST", + credentials: "same-origin", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + memberId, + subscriptionId: term.id, + operationKey: idempotency.current, + amountMinor: minor, + method, + externalReference: reference.trim(), + note: note.trim(), + }), + }); + const data = await result.json(); + if (!result.ok) throw new Error(data.error || "Unable to record payment"); + idempotency.current = null; + setReceipt(data.receiptUrl); + setAmount(""); + setReference(""); + setNote(""); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Recording failed"); + } finally { + setBusy(false); + } + } + return ( +
+
+
+

+ Club fee ledger +

+

Record a received payment

+
+ + + +
+

+ Only use this after cash, UPI, card or another payment has actually been received outside + ClubKeep. This action does not charge a member. +

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

+ No outstanding balance on the available active membership terms. Canceled and fully paid + terms cannot receive new collections. +

+ ) : ( +
+ + + + + +
+ + + + Permanent, auditable entry + +
+
+ )} + {error && ( +

+ {error} +

+ )} + {receipt && ( +

+ Collection recorded.{" "} + + View and print receipt + +

+ )} +
+ ); +} diff --git a/src/components/clubs/reverse-payment-button.tsx b/src/components/clubs/reverse-payment-button.tsx new file mode 100644 index 0000000..3684a4f --- /dev/null +++ b/src/components/clubs/reverse-payment-button.tsx @@ -0,0 +1,68 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { RotateCcw } from "lucide-react"; +export function ReversePaymentButton({ clubId, paymentId }: { clubId: string; paymentId: string }) { + const router = useRouter(); + const [busy, setBusy] = useState(false), + [error, setError] = useState(""); + async function reverse() { + if (busy) return; + const reason = window.prompt( + "Void this mistaken ClubKeep record? This does not issue a bank refund. Enter a reason (8–240 characters):" + ); + if (reason === null) return; + if (reason.trim().length < 8 || reason.trim().length > 240) { + setError("Reason must contain 8–240 characters"); + return; + } + if ( + !window.confirm( + "Permanently mark this receipt VOID and restore the recorded outstanding balance?" + ) + ) + return; + setBusy(true); + setError(""); + try { + const response = await fetch( + "/api/clubs/" + + encodeURIComponent(clubId) + + "/finance/payments/" + + encodeURIComponent(paymentId) + + "/reverse", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() }), + } + ); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Unable to void entry"); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Unable to void entry"); + } finally { + setBusy(false); + } + } + return ( +
+ + {error && ( + + {error} + + )} +
+ ); +} diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts new file mode 100644 index 0000000..557bcf0 --- /dev/null +++ b/src/lib/finance-service.ts @@ -0,0 +1,335 @@ +import type { PrismaClient } from "@/generated/prisma/client"; +import { requireClubActive } from "@/lib/member-service"; +import { OnboardingError } from "@/lib/club-onboarding"; +import type { RecordClubPaymentInput } from "@/lib/validations/finance"; + +function asSafeNumber(value: bigint | number | null): number { + const number = Number(value ?? 0); + if (!Number.isSafeInteger(number)) + throw new Error("Finance amount is outside safe integer range"); + return number; +} +export function financeBalance(priceMinor: number, paidMinor: number) { + return Math.max(0, priceMinor - paidMinor); +} +export function receiptNumber(sequence: number) { + return "CK-" + String(sequence).padStart(7, "0"); +} + +export async function recordClubPayment( + db: PrismaClient, + actorId: string, + clubId: string, + input: RecordClubPaymentInput +) { + return db.$transaction(async (tx) => { + await requireClubActive(tx, actorId, clubId, ["OWNER", "MANAGER", "STAFF"]); + // Acquire the club lock BEFORE the idempotency and balance checks; serialize all concurrent financial mutations. + const club = await tx.club.update({ + where: { id: clubId }, + data: { updatedAt: new Date() }, + select: { name: true }, + }); + const repeat = await tx.clubPayment.findUnique({ where: { operationKey: input.operationKey } }); + if (repeat) { + if ( + repeat.clubId !== clubId || + repeat.memberRecordId !== input.memberId || + repeat.subscriptionId !== input.subscriptionId || + repeat.recordedByUserId !== actorId || + repeat.amountMinor !== input.amountMinor || + repeat.method !== input.method || + (repeat.externalReference ?? "") !== input.externalReference || + (repeat.note ?? "") !== input.note + ) + throw new OnboardingError(409, "Idempotency key is already used for a different payment"); + return { id: repeat.id, receiptNo: repeat.receiptNo, repeated: true }; + } + // B1 renew/cancel/archive take the roster row lock. Share it to prevent + // recording a payment while a term is concurrently cancelled or archived. + const locked = await tx.$queryRaw<{ id: string }[]>` + SELECT "id" FROM "ClubMemberRecord" + WHERE "id"=${input.memberId} AND "clubId"=${clubId} AND "status"='ACTIVE' + FOR UPDATE + `; + if (locked.length !== 1) throw new OnboardingError(404, "Active club member not found"); + const term = await tx.memberSubscription.findFirst({ + where: { + id: input.subscriptionId, + clubId, + memberRecordId: input.memberId, + cancelledAt: null, + }, + select: { + id: true, + priceMinor: true, + currency: true, + planName: true, + startAt: true, + endAt: true, + memberRecord: { select: { status: true, memberNumber: true, displayName: true } }, + }, + }); + if (!term || term.memberRecord.status !== "ACTIVE") + throw new OnboardingError(404, "Active membership term not found"); + const sums = await tx.clubPayment.aggregate({ + where: { clubId, subscriptionId: term.id, reversal: { is: null } }, + _sum: { amountMinor: true }, + }); + if (input.amountMinor > financeBalance(term.priceMinor, sums._sum.amountMinor ?? 0)) + throw new OnboardingError(409, "Payment exceeds remaining membership dues"); + const receipt = await tx.club.update({ + where: { id: clubId }, + data: { receiptSequence: { increment: 1 } }, + select: { receiptSequence: true }, + }); + const record = await tx.clubPayment.create({ + data: { + clubId, + memberRecordId: input.memberId, + subscriptionId: term.id, + receiptNo: receipt.receiptSequence, + operationKey: input.operationKey, + amountMinor: input.amountMinor, + currency: term.currency, + method: input.method, + externalReference: input.externalReference || null, + note: input.note || null, + recordedByUserId: actorId, + clubName: club.name, + memberName: term.memberRecord.displayName, + memberNumber: term.memberRecord.memberNumber, + planName: term.planName, + termPriceMinor: term.priceMinor, + termStartsAt: term.startAt, + termEndsAt: term.endAt, + }, + select: { id: true, receiptNo: true }, + }); + await tx.securityEvent.create({ + data: { clubId, actorUserId: actorId, eventType: "PAYMENT_RECORDED", referenceId: record.id }, + }); + return { ...record, repeated: false }; + }); +} + +export async function reverseClubPayment( + db: PrismaClient, + actorId: string, + clubId: string, + paymentId: string, + reason: string +) { + return db.$transaction(async (tx) => { + await requireClubActive(tx, actorId, clubId, ["OWNER", "MANAGER"]); + await tx.club.update({ where: { id: clubId }, data: { updatedAt: new Date() } }); + const payment = await tx.clubPayment.findFirst({ + where: { clubId, id: paymentId }, + select: { + id: true, + reversal: { select: { id: true, reason: true, reversedByUserId: true } }, + }, + }); + if (!payment) throw new OnboardingError(404, "Payment not found"); + if (payment.reversal) { + if (payment.reversal.reason === reason && payment.reversal.reversedByUserId === actorId) + return { id: payment.reversal.id, repeated: true }; + throw new OnboardingError(409, "Payment has already been reversed"); + } + const reversal = await tx.clubPaymentReversal.create({ + data: { clubId, paymentId, reason, reversedByUserId: actorId }, + select: { id: true }, + }); + await tx.securityEvent.create({ + data: { clubId, actorUserId: actorId, eventType: "PAYMENT_REVERSED", referenceId: paymentId }, + }); + return { ...reversal, repeated: false }; + }); +} + +export type FinanceAmount = { + currency: string; + charged: number; + collected: number; + outstanding: number; + overdue: number; + terms: number; +}; +export async function clubFinanceSummary( + db: PrismaClient, + clubId: string +): Promise { + const termTotals = await db.$queryRaw< + Array<{ + currency: string; + charged: bigint; + outstanding: bigint; + overdue: bigint; + terms: bigint; + }> + >` + WITH paid AS ( + SELECT p."subscriptionId",SUM(p."amountMinor")::bigint AS value + FROM "ClubPayment" p LEFT JOIN "ClubPaymentReversal" r ON r."paymentId"=p."id" + WHERE p."clubId"=${clubId} AND r."id" IS NULL GROUP BY p."subscriptionId" + ) + SELECT t."currency",SUM(t."priceMinor")::bigint AS charged, + SUM(GREATEST(t."priceMinor"-COALESCE(paid.value,0),0))::bigint AS outstanding, + SUM(CASE WHEN t."endAt"(); + for (const t of termTotals) + totals.set(t.currency, { + currency: t.currency, + charged: asSafeNumber(t.charged), + collected: 0, + outstanding: asSafeNumber(t.outstanding), + overdue: asSafeNumber(t.overdue), + terms: asSafeNumber(t.terms), + }); + for (const p of received) { + const existing = totals.get(p.currency) ?? { + currency: p.currency, + charged: 0, + collected: 0, + outstanding: 0, + overdue: 0, + terms: 0, + }; + existing.collected = p._sum.amountMinor ?? 0; + totals.set(p.currency, existing); + } + return [...totals.values()].sort((a, b) => a.currency.localeCompare(b.currency)); +} +export async function recentClubPayments(db: PrismaClient, clubId: string, cursor?: string) { + return db.clubPayment.findMany({ + where: { clubId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 51, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + select: { + id: true, + receiptNo: true, + amountMinor: true, + currency: true, + method: true, + memberName: true, + memberRecordId: true, + planName: true, + createdAt: true, + receivedAt: true, + reversal: { select: { reason: true, createdAt: true } }, + }, + }); +} +export async function memberClubFinance(db: PrismaClient, clubId: string, memberId: string) { + const terms = await db.memberSubscription.findMany({ + where: { clubId, memberRecordId: memberId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take: 80, + select: { + id: true, + planName: true, + priceMinor: true, + currency: true, + createdAt: true, + cancelledAt: true, + endAt: true, + payments: { + select: { + id: true, + amountMinor: true, + receiptNo: true, + receivedAt: true, + method: true, + reversal: { select: { id: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10, + }, + }, + }); + const aggregates = terms.length + ? await db.clubPayment.groupBy({ + by: ["subscriptionId"], + where: { + clubId, + memberRecordId: memberId, + subscriptionId: { in: terms.map((t) => t.id) }, + reversal: { is: null }, + }, + _sum: { amountMinor: true }, + }) + : []; + const paidByTerm = new Map(aggregates.map((a) => [a.subscriptionId, a._sum.amountMinor ?? 0])); + return terms.map((term) => { + const paid = paidByTerm.get(term.id) ?? 0; + return { + ...term, + paidMinor: paid, + dueMinor: term.cancelledAt ? 0 : financeBalance(term.priceMinor, paid), + }; + }); +} +export async function clubDuesPage(db: PrismaClient, clubId: string, cursor?: string) { + const rows = await db.$queryRaw< + Array<{ + id: string; + memberRecordId: string; + memberName: string; + memberNumber: number; + planName: string; + currency: string; + priceMinor: number; + paidMinor: bigint; + dueMinor: bigint; + endAt: Date; + }> + >` + WITH paid AS ( + SELECT p."subscriptionId",SUM(p."amountMinor")::bigint AS value + FROM "ClubPayment" p LEFT JOIN "ClubPaymentReversal" r ON r."paymentId"=p."id" + WHERE p."clubId"=${clubId} AND r."id" IS NULL GROUP BY p."subscriptionId" + ) + SELECT t."id",t."memberRecordId",m."displayName" AS "memberName",m."memberNumber", + t."planName",t."currency",t."priceMinor",COALESCE(paid.value,0)::bigint AS "paidMinor", + GREATEST(t."priceMinor"-COALESCE(paid.value,0),0)::bigint AS "dueMinor",t."endAt" + FROM "MemberSubscription" t + JOIN "ClubMemberRecord" m ON m."id"=t."memberRecordId" AND m."clubId"=t."clubId" + LEFT JOIN paid ON paid."subscriptionId"=t."id" + WHERE t."clubId"=${clubId} AND t."cancelledAt" IS NULL + AND t."priceMinor">COALESCE(paid.value,0) + AND t."id">${cursor ?? ""} + ORDER BY t."id" ASC LIMIT 51 + `; + return rows.map((r) => ({ + ...r, + paidMinor: asSafeNumber(r.paidMinor), + dueMinor: asSafeNumber(r.dueMinor), + })); +} + +export async function financeMonthlyTrend(db: PrismaClient, clubId: string, timeZone: string) { + const cutoff = new Date(Date.now() - 215 * 86400000); + const rows = await db.$queryRaw>` + SELECT to_char(p."receivedAt" AT TIME ZONE ${timeZone},'YYYY-MM') AS month, + p."currency",SUM(p."amountMinor")::bigint AS amount + FROM "ClubPayment" p LEFT JOIN "ClubPaymentReversal" r ON r."paymentId"=p."id" + WHERE p."clubId"=${clubId} AND p."receivedAt">=${cutoff} AND r."id" IS NULL + GROUP BY month,p."currency" ORDER BY month ASC,p."currency" ASC + `; + return rows.map((row) => ({ + month: row.month, + currency: row.currency, + amount: asSafeNumber(row.amount), + })); +} diff --git a/src/lib/validations/finance.ts b/src/lib/validations/finance.ts new file mode 100644 index 0000000..e62a96b --- /dev/null +++ b/src/lib/validations/finance.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const recordClubPaymentInput = z + .object({ + memberId: z.string().trim().min(10).max(128), + subscriptionId: z.string().trim().min(10).max(128), + operationKey: z.uuid(), + amountMinor: z.number().int().min(1).max(100000000), + method: z.enum(["CASH", "UPI", "CARD", "OTHER"]), + externalReference: z.string().trim().max(120).optional().default(""), + note: z.string().trim().max(240).optional().default(""), + }) + .strict(); +export const reverseClubPaymentInput = z + .object({ reason: z.string().trim().min(8).max(240) }) + .strict(); +export type RecordClubPaymentInput = z.infer; diff --git a/tests/integration/b4-finance-schema.test.mjs b/tests/integration/b4-finance-schema.test.mjs new file mode 100644 index 0000000..e35d6cd --- /dev/null +++ b/tests/integration/b4-finance-schema.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomUUID } from "node:crypto"; +import pg from "pg"; +assert.ok(process.env.DATABASE_URL, "Run on disposable migrated ClubKeep PostgreSQL 17 only"); +const { Client } = pg; +async function withinTx(fn) { + const db = new Client({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 5000, + }); + await db.connect(); + try { + await db.query("BEGIN"); + await fn(db); + } finally { + await db.query("ROLLBACK"); + await db.end(); + } +} +async function reject(db, sql, values, code) { + await db.query("SAVEPOINT expected_failure"); + try { + await assert.rejects( + () => db.query(sql, values), + (err) => err.code === code + ); + } finally { + await db.query("ROLLBACK TO SAVEPOINT expected_failure"); + } +} +async function fixture(db) { + const n = randomUUID().slice(0, 8), + user = "b4-u-" + n, + club = "b4-a-" + n, + foreignClub = "b4-b-" + n; + const member = "b4-m-" + n, + plan = "b4-plan-" + n, + term = "b4-term-" + n; + await db.query( + 'INSERT INTO "user" ("id","name","email","updatedAt") VALUES ($1,\'Bookkeeper\',$2,NOW())', + [user, user + "@example.invalid"] + ); + await db.query( + 'INSERT INTO "Club" ("id","slug","name","updatedAt") VALUES ($1,$1,\'Club One\',NOW()),($2,$2,\'Club Two\',NOW())', + [club, foreignClub] + ); + await db.query( + 'INSERT INTO "ClubMemberRecord" ("id","clubId","memberNumber","displayName","updatedAt") VALUES ($1,$2,1,\'Walk-in Member\',NOW())', + [member, club] + ); + await db.query( + 'INSERT INTO "ClubPlan" ("id","clubId","name","durationUnit","durationCount","priceMinor","currency","updatedAt") VALUES ($1,$2,\'Monthly\',\'MONTHS\',1,15000,\'INR\',NOW())', + [plan, club] + ); + await db.query( + 'INSERT INTO "MemberSubscription" ("id","clubId","memberRecordId","planId","planName","priceMinor","currency","startAt","endAt","operationKey","createdByUserId") VALUES ($1,$2,$3,$4,\'Monthly\',15000,\'INR\',NOW(),NOW()+INTERVAL \'1 month\',$5,$6)', + [term, club, member, plan, "b4-term-op-" + n, user] + ); + return { n, user, club, foreignClub, member, term }; +} +function insertSQL() { + return 'INSERT INTO "ClubPayment" ("id","clubId","memberRecordId","subscriptionId","receiptNo","operationKey","amountMinor","currency","method","recordedByUserId","clubName","memberName","memberNumber","planName","termPriceMinor","termStartsAt","termEndsAt") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,\'CASH\',$9,\'Club One\',\'Walk-in Member\',1,\'Monthly\',15000,NOW(),NOW()+INTERVAL \'1 month\')'; +} +test("B4 denies cross-club member/term use, zero money, duplicate receipts and idempotency keys", async () => { + await withinTx(async (db) => { + const f = await fixture(db), + payment = "b4-pay-" + f.n, + key = "b4-payment-op-" + f.n; + const good = [payment, f.club, f.member, f.term, 1, key, 5000, "INR", f.user]; + await db.query(insertSQL(), good); + await reject( + db, + insertSQL(), + [ + "b4-foreign-" + f.n, + f.foreignClub, + f.member, + f.term, + 2, + key + "foreign", + 100, + "INR", + f.user, + ], + "23503" + ); + await reject( + db, + insertSQL(), + ["b4-zero-" + f.n, f.club, f.member, f.term, 2, key + "zero", 0, "INR", f.user], + "23514" + ); + await reject( + db, + insertSQL(), + ["b4-negative-" + f.n, f.club, f.member, f.term, 2, key + "negative", -1, "INR", f.user], + "23514" + ); + await reject( + db, + insertSQL(), + ["b4-dupno-" + f.n, f.club, f.member, f.term, 1, key + "duplicate", 1, "INR", f.user], + "23505" + ); + await reject( + db, + insertSQL(), + ["b4-dupkey-" + f.n, f.club, f.member, f.term, 2, key, 1, "INR", f.user], + "23505" + ); + await reject( + db, + insertSQL(), + ["b4-badcurrency-" + f.n, f.club, f.member, f.term, 2, key + "currency", 10, "123", f.user], + "23514" + ); + assert.equal( + (await db.query('SELECT count(*)::int n FROM "ClubPayment" WHERE "clubId"=$1', [f.club])) + .rows[0].n, + 1 + ); + }); +}); +test("B4 appends one reversal, never rewrites or deletes historical payments or reversals", async () => { + await withinTx(async (db) => { + const f = await fixture(db), + payment = "b4-pay-" + f.n, + key = "b4-payment-op-" + f.n; + await db.query(insertSQL(), [payment, f.club, f.member, f.term, 1, key, 5000, "INR", f.user]); + await reject( + db, + 'UPDATE "ClubPayment" SET "amountMinor"=2000 WHERE "id"=$1', + [payment], + "P0001" + ); + await reject(db, 'DELETE FROM "ClubPayment" WHERE "id"=$1', [payment], "P0001"); + await db.query( + 'INSERT INTO "ClubPaymentReversal" ("id","clubId","paymentId","reason","reversedByUserId") VALUES ($1,$2,$3,$4,$5)', + ["b4-rev-" + f.n, f.club, payment, "Mistaken collection entered twice", f.user] + ); + await reject( + db, + 'INSERT INTO "ClubPaymentReversal" ("id","clubId","paymentId","reason","reversedByUserId") VALUES ($1,$2,$3,$4,$5)', + ["b4-other-rev-" + f.n, f.club, payment, "Mistaken collection entered twice", f.user], + "23505" + ); + await reject( + db, + 'UPDATE "ClubPaymentReversal" SET "reason"=$2 WHERE "paymentId"=$1', + [payment, "Some other reason"], + "P0001" + ); + await reject(db, 'DELETE FROM "ClubPaymentReversal" WHERE "paymentId"=$1', [payment], "P0001"); + const p = ( + await db.query( + 'SELECT p."amountMinor",r."reason" FROM "ClubPayment" p LEFT JOIN "ClubPaymentReversal" r ON r."paymentId"=p."id" WHERE p."id"=$1', + [payment] + ) + ).rows[0]; + assert.equal(p.amountMinor, 5000); + assert.equal(p.reason, "Mistaken collection entered twice"); + await db.query('UPDATE "MemberSubscription" SET "cancelledAt"=NOW() WHERE "id"=$1', [f.term]); + assert.equal( + (await db.query('SELECT count(*)::int n FROM "ClubPayment" WHERE "id"=$1', [payment])).rows[0] + .n, + 1 + ); + }); +});