From 33c8ee890cc0af5dc348224c98b1e98b71e52281 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:34:48 +0530 Subject: [PATCH 01/12] docs(B4-A): lock club collection, cancellation and receipt contract --- docs/B4_CLUB_FINANCE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/B4_CLUB_FINANCE.md 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. From 191fc70cb447c85d38d1f3590ca3f690a9a0a904 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:35:53 +0530 Subject: [PATCH 02/12] feat(B4-B): append-only tenant-scoped payment, reversal and receipt schema --- .../migration.sql | 76 +++++++++++++++++++ prisma/schema.prisma | 73 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 prisma/migrations/20261112010000_b4_club_finance/migration.sql 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..c27f162 --- /dev/null +++ b/prisma/migrations/20261112010000_b4_club_finance/migration.sql @@ -0,0 +1,76 @@ +-- B4 ledger: additive, no changes to membership terms or existing prices. +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 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..64afd08 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") } @@ -172,6 +174,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 +193,8 @@ model Club { subscriptions MemberSubscription[] memberEvents MemberEvent[] attendanceEvents ClubAttendance[] + payments ClubPayment[] + paymentReversals ClubPaymentReversal[] } model ClubMembership { @@ -320,6 +325,7 @@ model ClubMemberRecord { subscriptions MemberSubscription[] events MemberEvent[] attendanceEvents ClubAttendance[] + payments ClubPayment[] @@unique([clubId, id]) @@unique([clubId, memberNumber]) @@ -365,7 +371,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 +492,68 @@ 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) + + @@index([clubId, createdAt(sort: Desc)]) +} From 250678ee80e42a1206329d0791d1231fc698fb6b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:39:20 +0530 Subject: [PATCH 03/12] feat(B4-C): atomic currency-safe collection, reversal and dues services --- .../migration.sql | 2 + prisma/schema.prisma | 2 + src/lib/finance-service.ts | 146 ++++++++++++++++++ src/lib/validations/finance.ts | 13 ++ 4 files changed, 163 insertions(+) create mode 100644 src/lib/finance-service.ts create mode 100644 src/lib/validations/finance.ts diff --git a/prisma/migrations/20261112010000_b4_club_finance/migration.sql b/prisma/migrations/20261112010000_b4_club_finance/migration.sql index c27f162..3795f22 100644 --- a/prisma/migrations/20261112010000_b4_club_finance/migration.sql +++ b/prisma/migrations/20261112010000_b4_club_finance/migration.sql @@ -1,4 +1,6 @@ -- 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64afd08..c7c76c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,6 +162,8 @@ enum SecurityEventType { MEMBERSHIP_CHANGED SECURITY_REVIEW CLUB_TIMEZONE_CHANGED + PAYMENT_RECORDED + PAYMENT_REVERSED } model Club { diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts new file mode 100644 index 0000000..37bd7ba --- /dev/null +++ b/src/lib/finance-service.ts @@ -0,0 +1,146 @@ +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}; + } + 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>` + 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:80}} + }); + return terms.map(term=>{ + const paid=term.payments.reduce((sum,p)=>sum+(p.reversal?0:p.amountMinor),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>` + 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)})); +} diff --git a/src/lib/validations/finance.ts b/src/lib/validations/finance.ts new file mode 100644 index 0000000..036f80e --- /dev/null +++ b/src/lib/validations/finance.ts @@ -0,0 +1,13 @@ +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; From 1d13ea94fa944289b33ae3657cd08111d572238e Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:40:09 +0530 Subject: [PATCH 04/12] feat(B4-D): protected payment recording, paginated ledger and reversal APIs --- .../payments/[paymentId]/reverse/route.ts | 22 ++++++++++++ .../clubs/[clubId]/finance/payments/route.ts | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts create mode 100644 src/app/api/clubs/[clubId]/finance/payments/route.ts 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..50046e5 --- /dev/null +++ b/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts @@ -0,0 +1,22 @@ +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..a99ff77 --- /dev/null +++ b/src/app/api/clubs/[clubId]/finance/payments/route.ts @@ -0,0 +1,36 @@ +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);} +} From af92cd311b32f43cccb2f2254da26ffa96402d60 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:43:00 +0530 Subject: [PATCH 05/12] feat(B4-E): Luxe finance dashboard, multi-currency reporting and paginated dues/ledger --- src/app/clubs/[clubId]/finance/dues/page.tsx | 35 +++++++++ src/app/clubs/[clubId]/finance/page.tsx | 76 +++++++++++++++++++ .../clubs/[clubId]/finance/payments/page.tsx | 39 ++++++++++ src/components/clubs/business-shell.tsx | 8 +- src/lib/finance-service.ts | 12 +++ 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/app/clubs/[clubId]/finance/dues/page.tsx create mode 100644 src/app/clubs/[clubId]/finance/page.tsx create mode 100644 src/app/clubs/[clubId]/finance/payments/page.tsx 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..1336add --- /dev/null +++ b/src/app/clubs/[clubId]/finance/dues/page.tsx @@ -0,0 +1,35 @@ +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)&&Next page } +

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..da2bb70 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/page.tsx @@ -0,0 +1,76 @@ +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..2d7ca98 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/payments/page.tsx @@ -0,0 +1,39 @@ +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)&&Next page } +
; +} diff --git a/src/components/clubs/business-shell.tsx b/src/components/clubs/business-shell.tsx index 186e683..587e17e 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,7 @@ 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 +155,7 @@ 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/lib/finance-service.ts b/src/lib/finance-service.ts index 37bd7ba..a910bfa 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -144,3 +144,15 @@ export async function clubDuesPage(db:PrismaClient,clubId:string,cursor?:string) `; 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)})); +} From d8b58469333c39132d850471e2b79a829dbcbaa0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:44:44 +0530 Subject: [PATCH 06/12] feat(B4-F): member balances, external-fee entry form and auditable reversal controls --- .../[clubId]/members/[memberId]/page.tsx | 15 +++++ .../clubs/record-club-payment-form.tsx | 57 +++++++++++++++++++ .../clubs/reverse-payment-button.tsx | 26 +++++++++ src/lib/finance-service.ts | 8 ++- 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/components/clubs/record-club-payment-form.tsx create mode 100644 src/components/clubs/reverse-payment-button.tsx diff --git a/src/app/clubs/[clubId]/members/[memberId]/page.tsx b/src/app/clubs/[clubId]/members/[memberId]/page.tsx index f88e045..e7cc21c 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,17 @@ 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/record-club-payment-form.tsx b/src/components/clubs/record-club-payment-form.tsx new file mode 100644 index 0000000..39db4d2 --- /dev/null +++ b/src/components/clubs/record-club-payment-form.tsx @@ -0,0 +1,57 @@ +"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..3c845f6 --- /dev/null +++ b/src/components/clubs/reverse-payment-button.tsx @@ -0,0 +1,26 @@ +"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 index a910bfa..1d7b082 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -115,10 +115,14 @@ export async function memberClubFinance(db:PrismaClient,clubId:string,memberId:s 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:80}} + 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=term.payments.reduce((sum,p)=>sum+(p.reversal?0:p.amountMinor),0); + const paid=paidByTerm.get(term.id)??0; return {...term,paidMinor:paid,dueMinor:term.cancelledAt?0:financeBalance(term.priceMinor,paid)}; }); } From 8366e6b5ce3cd79940fab4fd41b164deaf189095 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:46:05 +0530 Subject: [PATCH 07/12] feat(B4-G): private printable branded receipts with immutable payment snapshots --- .../finance/receipts/[paymentId]/page.tsx | 76 +++++++++++++++++++ .../clubs/print-club-receipt-button.tsx | 8 ++ 2 files changed, 84 insertions(+) create mode 100644 src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx create mode 100644 src/components/clubs/print-club-receipt-button.tsx 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..2429c22 --- /dev/null +++ b/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx @@ -0,0 +1,76 @@ +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/components/clubs/print-club-receipt-button.tsx b/src/components/clubs/print-club-receipt-button.tsx new file mode 100644 index 0000000..f2eda65 --- /dev/null +++ b/src/components/clubs/print-club-receipt-button.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Printer } from "lucide-react"; +export function PrintClubReceiptButton(){ + return ; +} From 7bd5a5e5887ebd3d884516422148efc32e887fa0 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:48:13 +0530 Subject: [PATCH 08/12] test(B4-H): PG17 append-only ledger regression and CI finance gates --- .github/workflows/b4-formatter.yml | 34 +++++++++++ .github/workflows/ci.yml | 2 + .../migration.sql | 1 + prisma/schema.prisma | 1 + tests/integration/b4-finance-schema.test.mjs | 58 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 .github/workflows/b4-formatter.yml create mode 100644 tests/integration/b4-finance-schema.test.mjs diff --git a/.github/workflows/b4-formatter.yml b/.github/workflows/b4-formatter.yml new file mode 100644 index 0000000..ee7d74a --- /dev/null +++ b/.github/workflows/b4-formatter.yml @@ -0,0 +1,34 @@ +name: B4 transient source formatter +on: + push: + branches: [feat/b4-club-finance-dues-receipts] +permissions: + contents: write +jobs: + format: + if: ${{ !contains(github.event.head_commit.message, '[b4-format-done]') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: true + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + env: + HUSKY: "0" + - name: Apply project Prettier on B4 feature branch + run: | + set -euo pipefail + test "$GITHUB_REF_NAME" = "feat/b4-club-finance-dues-receipts" + npx prettier --write src tests prisma.config.ts next.config.ts + if ! git diff --quiet -- src tests prisma.config.ts next.config.ts; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src tests prisma.config.ts next.config.ts + git commit -m "style(B4): format financial source [b4-format-done]" + git push origin "HEAD:$GITHUB_REF_NAME" + fi 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/prisma/migrations/20261112010000_b4_club_finance/migration.sql b/prisma/migrations/20261112010000_b4_club_finance/migration.sql index 3795f22..1cd4c3d 100644 --- a/prisma/migrations/20261112010000_b4_club_finance/migration.sql +++ b/prisma/migrations/20261112010000_b4_club_finance/migration.sql @@ -62,6 +62,7 @@ CREATE TABLE "ClubPaymentReversal" ( 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. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7c76c0..5e9292e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -557,5 +557,6 @@ model ClubPaymentReversal { 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/tests/integration/b4-finance-schema.test.mjs b/tests/integration/b4-finance-schema.test.mjs new file mode 100644 index 0000000..8f4eb84 --- /dev/null +++ b/tests/integration/b4-finance-schema.test.mjs @@ -0,0 +1,58 @@ +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); + }); +}); From 2635786766d5e7b6e227d362691475abc0b70e62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:19:47 +0000 Subject: [PATCH 09/12] style(B4): format financial source [b4-format-done] --- .../payments/[paymentId]/reverse/route.ts | 48 ++- .../clubs/[clubId]/finance/payments/route.ts | 76 ++-- src/app/clubs/[clubId]/finance/dues/page.tsx | 111 ++++-- src/app/clubs/[clubId]/finance/page.tsx | 257 ++++++++++--- .../clubs/[clubId]/finance/payments/page.tsx | 120 ++++-- .../finance/receipts/[paymentId]/page.tsx | 250 +++++++++--- .../[clubId]/members/[memberId]/page.tsx | 72 +++- src/components/clubs/business-shell.tsx | 10 +- .../clubs/print-club-receipt-button.tsx | 16 +- .../clubs/record-club-payment-form.tsx | 251 +++++++++--- .../clubs/reverse-payment-button.tsx | 80 +++- src/lib/finance-service.ts | 363 +++++++++++++----- src/lib/validations/finance.ts | 26 +- tests/integration/b4-finance-schema.test.mjs | 200 +++++++--- 14 files changed, 1426 insertions(+), 454 deletions(-) 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 index 50046e5..d9255c9 100644 --- a/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts +++ b/src/app/api/clubs/[clubId]/finance/payments/[paymentId]/reverse/route.ts @@ -3,20 +3,38 @@ 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"; +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);} +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 index a99ff77..8eb9ce7 100644 --- a/src/app/api/clubs/[clubId]/finance/payments/route.ts +++ b/src/app/api/clubs/[clubId]/finance/payments/route.ts @@ -2,35 +2,57 @@ 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 { 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"; +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);} +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);} +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 index 1336add..d03eb17 100644 --- a/src/app/clubs/[clubId]/finance/dues/page.tsx +++ b/src/app/clubs/[clubId]/finance/dues/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import Link from "next/link"; -import { CalendarClock,ArrowRight } from "lucide-react"; +import { CalendarClock, ArrowRight } from "lucide-react"; import type { Metadata } from "next"; import { requireSession } from "@/lib/required-session"; import { requireClubActive } from "@/lib/member-service"; @@ -9,27 +9,92 @@ 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…}>; +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)&&Next page } -

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

-
; +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 index da2bb70..0e30a36 100644 --- a/src/app/clubs/[clubId]/finance/page.tsx +++ b/src/app/clubs/[clubId]/finance/page.tsx @@ -1,76 +1,209 @@ import { Suspense } from "react"; import Link from "next/link"; -import { WalletCards,Coins,CalendarClock,ReceiptText,ArrowUpRight,History,ArrowRight } from "lucide-react"; +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 { + 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…}>; +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) +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}

-
)} + 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)} + +
  • + ))} +
+ )} +
-
)} -
-
-

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. +

-

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 index 2d7ca98..3bd6c0c 100644 --- a/src/app/clubs/[clubId]/finance/payments/page.tsx +++ b/src/app/clubs/[clubId]/finance/payments/page.tsx @@ -1,39 +1,105 @@ import { Suspense } from "react"; import Link from "next/link"; -import { History,ArrowRight } from "lucide-react"; +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 { 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…}>; +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)&&Next page } -
; +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 index 2429c22..3a92e10 100644 --- a/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx +++ b/src/app/clubs/[clubId]/finance/receipts/[paymentId]/page.tsx @@ -2,75 +2,203 @@ 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 { 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…}>; +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)} +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
- {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. +
+
+
+

+ 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.

-
-
; +
+

+ + 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 e7cc21c..ead0b46 100644 --- a/src/app/clubs/[clubId]/members/[memberId]/page.tsx +++ b/src/app/clubs/[clubId]/members/[memberId]/page.tsx @@ -73,7 +73,7 @@ async function MemberContent({ params }: Props) { }), ]); if (!member) notFound(); - const financialTerms=await memberClubFinance(prisma,clubId,member.id); + 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 }, @@ -189,16 +189,68 @@ 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}))}/> + !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}
)} -
)}
} +

+ 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 587e17e..6ac4afb 100644 --- a/src/components/clubs/business-shell.tsx +++ b/src/components/clubs/business-shell.tsx @@ -45,7 +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}] : []), + ...(context.role === "OWNER" || context.role === "MANAGER" + ? [{ key: "finance", label: "Finance & dues", href: "/finance", icon: WalletCards }] + : []), ...(isOwner ? [ { @@ -155,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", "finance"].includes(x.key)|| (context.role==="STAFF"&&x.key==="renewals")) + .filter( + (x) => + ["overview", "attendance", "members", "finance"].includes(x.key) || + (context.role === "STAFF" && x.key === "renewals") + ) .map(({ key, label, href, icon: Icon }) => ( window.print()} - className="inline-flex min-h-12 items-center gap-2 rounded-xl bg-gradient-to-r from-[#e9bc78] to-[#f26b00] px-5 text-sm font-bold text-[#291807]"> - Print receipt - ; +export function PrintClubReceiptButton() { + return ( + + ); } diff --git a/src/components/clubs/record-club-payment-form.tsx b/src/components/clubs/record-club-payment-form.tsx index 39db4d2..549592d 100644 --- a/src/components/clubs/record-club-payment-form.tsx +++ b/src/components/clubs/record-club-payment-form.tsx @@ -1,57 +1,210 @@ "use client"; -import { useRef,useState,type FormEvent } from "react"; +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()}) +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(""); + 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);} + } 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 + return ( +
+
+
+

+ Club fee ledger +

+

Record a received payment

- } - {error&&

{error}

} - {receipt&&

Collection recorded. View and print receipt

} -
; + + + +
+

+ 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 index 3c845f6..3684a4f 100644 --- a/src/components/clubs/reverse-payment-button.tsx +++ b/src/components/clubs/reverse-payment-button.tsx @@ -2,25 +2,67 @@ 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"); +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);} + } catch (e) { + setError(e instanceof Error ? e.message : "Unable to void entry"); + } finally { + setBusy(false); + } } - return
- {error&&{error}} -
; + return ( +
+ + {error && ( + + {error} + + )} +
+ ); } diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 1d7b082..ff1e9b5 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -3,79 +3,163 @@ 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"); +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 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"]); +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}; + 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 }; } - 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} + 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 }, }); - 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}; + 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"); +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}; + 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>` +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" @@ -89,47 +173,120 @@ export async function clubFinanceSummary(db:PrismaClient,clubId:string):Promise< WHERE t."clubId"=${clubId} AND t."cancelledAt" IS NULL GROUP BY t."currency" `; - const received=await db.clubPayment.groupBy({by:["currency"],where:{clubId,reversal:{is:null}}, - _sum:{amountMinor:true}}); - const totals=new Map(); - 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) + const received = await db.clubPayment.groupBy({ + by: ["currency"], + where: { clubId, reversal: { is: null } }, + _sum: { amountMinor: true }, }); - 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); + const totals = new Map(); + 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)); + 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 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}} +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)}; + 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>` +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" @@ -143,20 +300,28 @@ export async function clubDuesPage(db:PrismaClient,clubId:string,cursor?:string) 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??''} + AND t."id">${cursor ?? ""} ORDER BY t."id" ASC LIMIT 51 `; - return rows.map(r=>({...r,paidMinor:asSafeNumber(r.paidMinor),dueMinor:asSafeNumber(r.dueMinor)})); + 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>` +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)})); + 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 index 036f80e..e62a96b 100644 --- a/src/lib/validations/finance.ts +++ b/src/lib/validations/finance.ts @@ -1,13 +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; +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 index 8f4eb84..e35d6cd 100644 --- a/tests/integration/b4-finance-schema.test.mjs +++ b/tests/integration/b4-finance-schema.test.mjs @@ -2,57 +2,169 @@ 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();} +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){ +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");} + 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}; +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(){ +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 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); +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 + ); }); }); From ff8b79b8564accec868d3826de11fd3ae91b3c9b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:51:21 +0530 Subject: [PATCH 10/12] fix(B4): serialize fee collection against B1 renewal, cancellation and archive locks --- src/lib/finance-service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index ff1e9b5..0d8294b 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -45,6 +45,14 @@ export async function recordClubPayment( 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, From 3c029923c698e5f35533bee86d8cd0ebba999196 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:22:26 +0000 Subject: [PATCH 11/12] style(B4): format financial source [b4-format-done] --- src/lib/finance-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 0d8294b..557bcf0 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -47,12 +47,12 @@ export async function recordClubPayment( } // 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}[]>` + 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"); + if (locked.length !== 1) throw new OnboardingError(404, "Active club member not found"); const term = await tx.memberSubscription.findFirst({ where: { id: input.subscriptionId, From 7e6c20b76420dd04829de44f76b728f9ed780f00 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Thu, 12 Nov 2026 03:53:35 +0530 Subject: [PATCH 12/12] docs(B4): reconcile merged phases and retire temporary source formatter --- .github/workflows/b4-formatter.yml | 34 ------------------------------ docs/PRODUCT.md | 2 +- docs/ROADMAP.md | 15 ++++++++----- 3 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 .github/workflows/b4-formatter.yml diff --git a/.github/workflows/b4-formatter.yml b/.github/workflows/b4-formatter.yml deleted file mode 100644 index ee7d74a..0000000 --- a/.github/workflows/b4-formatter.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: B4 transient source formatter -on: - push: - branches: [feat/b4-club-finance-dues-receipts] -permissions: - contents: write -jobs: - format: - if: ${{ !contains(github.event.head_commit.message, '[b4-format-done]') }} - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: true - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .nvmrc - cache: npm - - run: npm ci - env: - HUSKY: "0" - - name: Apply project Prettier on B4 feature branch - run: | - set -euo pipefail - test "$GITHUB_REF_NAME" = "feat/b4-club-finance-dues-receipts" - npx prettier --write src tests prisma.config.ts next.config.ts - if ! git diff --quiet -- src tests prisma.config.ts next.config.ts; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src tests prisma.config.ts next.config.ts - git commit -m "style(B4): format financial source [b4-format-done]" - git push origin "HEAD:$GITHUB_REF_NAME" - fi 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.