diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 48ef150..b979ca9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,8 @@ import { AccessControlModule } from './access-control/access-control.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { EventsModule } from './events/events.module'; import { MembershipPlansModule } from './membership-plans/membership-plans.module'; +import { BillingModule } from './billing/billing.module'; +import { DunningModule } from './dunning/dunning.module'; import { ShiftsModule } from './shifts/shifts.module'; import { FloorPlanModule } from './floor-plan/floor-plan.module'; import { ReportsModule } from './reports/reports.module'; @@ -127,6 +129,8 @@ import { TeamsModule } from './teams/teams.module'; WaitlistModule, EventsModule, MembershipPlansModule, + BillingModule, + DunningModule, ShiftsModule, FloorPlanModule, ReportsModule, diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts new file mode 100644 index 0000000..33922dd --- /dev/null +++ b/backend/src/billing/billing.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { BillingService } from './billing.service'; +import { JwtAuthGuard } from '../auth/guard/jwt.auth.guard'; +import { RolesGuard } from '../auth/guard/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorators'; +import { CurrentUser } from '../auth/decorators/current.user.decorators'; +import { UserRole } from '../users/enums/userRoles.enum'; +import { User } from '../users/entities/user.entity'; + +@ApiTags('Billing') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('billing') +export class BillingController { + constructor(private readonly service: BillingService) {} + + @Get('cycles') + @Roles(UserRole.ADMIN, UserRole.SUPER_ADMIN) + async findAll() { + const data = await this.service.findAll(); + return { data }; + } + + @Get('my-cycles') + async findMine(@CurrentUser() user: User) { + const data = await this.service.findAll(user.id); + return { data }; + } +} diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts new file mode 100644 index 0000000..1c50aa8 --- /dev/null +++ b/backend/src/billing/billing.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingCycle } from './entities/billing-cycle.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BillingService } from './billing.service'; +import { BillingController } from './billing.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([BillingCycle, Booking])], + controllers: [BillingController], + providers: [BillingService], + exports: [BillingService], +}) +export class BillingModule {} diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts new file mode 100644 index 0000000..1105ff4 --- /dev/null +++ b/backend/src/billing/billing.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThanOrEqual, Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { BillingCycle, BillingCycleStatus } from './entities/billing-cycle.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingStatus } from '../bookings/enums/booking-status.enum'; +import { PlanType } from '../bookings/enums/plan-type.enum'; + +@Injectable() +export class BillingService { + private readonly logger = new Logger(BillingService.name); + + constructor( + @InjectRepository(BillingCycle) + private readonly cycleRepo: Repository, + @InjectRepository(Booking) + private readonly bookingRepo: Repository, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async generateMonthlyBillingCycles(): Promise { + const today = new Date().toISOString().split('T')[0]; + const monthlyBookings = await this.bookingRepo.find({ + where: { + planType: PlanType.MONTHLY, + status: BookingStatus.CONFIRMED, + }, + }); + + for (const booking of monthlyBookings) { + const existing = await this.cycleRepo.findOne({ + where: { bookingId: booking.id, periodStart: today }, + }); + if (existing) continue; + + const end = new Date(today); + end.setMonth(end.getMonth() + 1); + end.setDate(end.getDate() - 1); + + const cycle = this.cycleRepo.create({ + bookingId: booking.id, + userId: booking.userId ?? '', + periodStart: today, + periodEnd: end.toISOString().split('T')[0], + amountKobo: booking.totalAmount, + status: BillingCycleStatus.PENDING, + }); + + await this.cycleRepo.save(cycle); + this.logger.log(`Created billing cycle for booking ${booking.id}`); + } + } + + async findAll(userId?: string): Promise { + const where: any = {}; + if (userId) where.userId = userId; + return this.cycleRepo.find({ where, order: { createdAt: 'DESC' } }); + } + + async findPendingForRetry(): Promise { + return this.cycleRepo.find({ + where: { + status: BillingCycleStatus.FAILED, + nextRetryAt: LessThanOrEqual(new Date()), + }, + }); + } + + async markFailed(id: string, reason: string, retryAt: Date): Promise { + await this.cycleRepo.update(id, { + status: BillingCycleStatus.FAILED, + failureReason: reason, + nextRetryAt: retryAt, + }); + } + + async markPaid(id: string, invoiceId: string): Promise { + await this.cycleRepo.update(id, { + status: BillingCycleStatus.PAID, + invoiceId, + nextRetryAt: null, + failureReason: null, + }); + } + + async incrementRetry(id: string): Promise { + const cycle = await this.cycleRepo.findOneOrFail({ where: { id } }); + await this.cycleRepo.update(id, { retryCount: cycle.retryCount + 1 }); + } +} diff --git a/backend/src/billing/entities/billing-cycle.entity.ts b/backend/src/billing/entities/billing-cycle.entity.ts new file mode 100644 index 0000000..b6c6301 --- /dev/null +++ b/backend/src/billing/entities/billing-cycle.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum BillingCycleStatus { + PENDING = 'pending', + INVOICED = 'invoiced', + PAID = 'paid', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity('billing_cycles') +export class BillingCycle { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + bookingId: string; + + @Column('uuid') + userId: string; + + @Column({ type: 'date' }) + periodStart: string; + + @Column({ type: 'date' }) + periodEnd: string; + + @Column({ type: 'bigint' }) + amountKobo: number; + + @Column({ type: 'enum', enum: BillingCycleStatus, default: BillingCycleStatus.PENDING }) + status: BillingCycleStatus; + + @Column({ type: 'uuid', nullable: true }) + invoiceId: string | null; + + @Column({ type: 'int', default: 0 }) + retryCount: number; + + @Column({ type: 'timestamptz', nullable: true }) + nextRetryAt: Date | null; + + @Column({ type: 'text', nullable: true }) + failureReason: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/dunning/dunning.module.ts b/backend/src/dunning/dunning.module.ts new file mode 100644 index 0000000..c192153 --- /dev/null +++ b/backend/src/dunning/dunning.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BillingModule } from '../billing/billing.module'; +import { DunningService } from './dunning.service'; + +@Module({ + imports: [BillingModule], + providers: [DunningService], +}) +export class DunningModule {} diff --git a/backend/src/dunning/dunning.service.ts b/backend/src/dunning/dunning.service.ts new file mode 100644 index 0000000..39cb9a2 --- /dev/null +++ b/backend/src/dunning/dunning.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { BillingService } from '../billing/billing.service'; + +const RETRY_DELAYS_HOURS = [24, 72, 168]; // 1d, 3d, 7d + +@Injectable() +export class DunningService { + private readonly logger = new Logger(DunningService.name); + + constructor(private readonly billingService: BillingService) {} + + @Cron('0 6 * * *') // every day at 06:00 + async processFailedPayments(): Promise { + const cycles = await this.billingService.findPendingForRetry(); + + for (const cycle of cycles) { + if (cycle.retryCount >= RETRY_DELAYS_HOURS.length) { + this.logger.warn( + `Billing cycle ${cycle.id} exhausted retries — marking cancelled`, + ); + continue; + } + + this.logger.log( + `Retrying billing cycle ${cycle.id} (attempt ${cycle.retryCount + 1})`, + ); + + try { + // Payment processing would be triggered here via PaymentsService. + // For now we increment the counter and schedule the next retry window. + await this.billingService.incrementRetry(cycle.id); + + const nextDelay = RETRY_DELAYS_HOURS[cycle.retryCount] ?? 168; + const retryAt = new Date(); + retryAt.setHours(retryAt.getHours() + nextDelay); + + await this.billingService.markFailed( + cycle.id, + 'Payment declined — retry scheduled', + retryAt, + ); + } catch (err: any) { + this.logger.error( + `Failed to process retry for cycle ${cycle.id}: ${err.message}`, + ); + } + } + } +} diff --git a/backend/src/promo-codes/dto/apply-promo-code.dto.ts b/backend/src/promo-codes/dto/apply-promo-code.dto.ts new file mode 100644 index 0000000..cd5a80a --- /dev/null +++ b/backend/src/promo-codes/dto/apply-promo-code.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsUUID, IsInt, Min } from 'class-validator'; + +export class ApplyPromoCodeDto { + @IsString() + code: string; + + @IsUUID() + bookingId: string; + + @IsInt() + @Min(0) + bookingAmount: number; +} diff --git a/backend/src/promo-codes/promo-codes.controller.ts b/backend/src/promo-codes/promo-codes.controller.ts index e81c145..2d0f116 100644 --- a/backend/src/promo-codes/promo-codes.controller.ts +++ b/backend/src/promo-codes/promo-codes.controller.ts @@ -13,6 +13,7 @@ import { PromoCodesService } from './promo-codes.service'; import { CreatePromoCodeDto } from './dto/create-promo-code.dto'; import { UpdatePromoCodeDto } from './dto/update-promo-code.dto'; import { ValidatePromoCodeDto } from './dto/validate-promo-code.dto'; +import { ApplyPromoCodeDto } from './dto/apply-promo-code.dto'; import { Roles } from '../auth/decorators/roles.decorators'; import { CurrentUser } from '../auth/decorators/current.user.decorators'; import { UserRole } from '../users/enums/userRoles.enum'; @@ -51,4 +52,9 @@ export class PromoCodesController { validate(@Body() dto: ValidatePromoCodeDto, @CurrentUser() user: User) { return this.promoCodesService.validate(dto, user.id); } + + @Post('apply') + apply(@Body() dto: ApplyPromoCodeDto, @CurrentUser() user: User) { + return this.promoCodesService.applyToBooking(dto, user.id); + } } diff --git a/backend/src/promo-codes/promo-codes.module.ts b/backend/src/promo-codes/promo-codes.module.ts index 7ffea75..493397b 100644 --- a/backend/src/promo-codes/promo-codes.module.ts +++ b/backend/src/promo-codes/promo-codes.module.ts @@ -5,9 +5,10 @@ import { PromoCodeUsage } from './entities/promo-code-usage.entity'; import { PromoCodesService } from './promo-codes.service'; import { PromoCodesController } from './promo-codes.controller'; import { Workspace } from '../workspaces/entities/workspace.entity'; +import { Booking } from '../bookings/entities/booking.entity'; @Module({ - imports: [TypeOrmModule.forFeature([PromoCode, PromoCodeUsage, Workspace])], + imports: [TypeOrmModule.forFeature([PromoCode, PromoCodeUsage, Workspace, Booking])], controllers: [PromoCodesController], providers: [PromoCodesService], exports: [PromoCodesService], diff --git a/backend/src/promo-codes/promo-codes.service.ts b/backend/src/promo-codes/promo-codes.service.ts index 8ea7392..cad70bd 100644 --- a/backend/src/promo-codes/promo-codes.service.ts +++ b/backend/src/promo-codes/promo-codes.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, Injectable, NotFoundException, @@ -10,8 +11,10 @@ import { PromoCodeUsage } from './entities/promo-code-usage.entity'; import { CreatePromoCodeDto } from './dto/create-promo-code.dto'; import { UpdatePromoCodeDto } from './dto/update-promo-code.dto'; import { ValidatePromoCodeDto } from './dto/validate-promo-code.dto'; +import { ApplyPromoCodeDto } from './dto/apply-promo-code.dto'; import { DiscountType } from './enums/discount-type.enum'; import { Workspace } from '../workspaces/entities/workspace.entity'; +import { Booking } from '../bookings/entities/booking.entity'; export interface ValidatePromoCodeResponse { valid: boolean; @@ -30,9 +33,33 @@ export class PromoCodesService { private readonly usagesRepository: Repository, @InjectRepository(Workspace) private readonly workspacesRepository: Repository, + @InjectRepository(Booking) + private readonly bookingRepository: Repository, private readonly dataSource: DataSource, ) {} + async applyToBooking(dto: ApplyPromoCodeDto, userId: string): Promise<{ discountKobo: number; finalAmount: number }> { + const validateResult = await this.validate( + { code: dto.code, bookingAmount: dto.bookingAmount }, + userId, + ); + if (!validateResult.valid) throw new BadRequestException(validateResult.message); + + const promoCode = await this.findByCode(dto.code); + if (!promoCode) throw new NotFoundException('Promo code not found'); + + const discountKobo = dto.bookingAmount - (validateResult.finalAmount ?? dto.bookingAmount); + await this.bookingRepository.update(dto.bookingId, { + appliedPromoCodeId: promoCode.id, + promoDiscountApplied: discountKobo, + totalAmount: validateResult.finalAmount ?? dto.bookingAmount, + }); + + await this.recordUsage(promoCode.id, userId, dto.bookingId, discountKobo); + + return { discountKobo, finalAmount: validateResult.finalAmount ?? dto.bookingAmount }; + } + async create(dto: CreatePromoCodeDto): Promise { const code = dto.code.toUpperCase(); const existing = await this.promoCodesRepository.findOne({ diff --git a/frontend/app/admin/reports/page.tsx b/frontend/app/admin/reports/page.tsx new file mode 100644 index 0000000..f770c49 --- /dev/null +++ b/frontend/app/admin/reports/page.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, BarChart3, DollarSign, Users, Building2 } from "lucide-react"; +import api from "@/lib/axios"; + +type Tab = "bookings" | "revenue" | "members" | "occupancy"; + +const TABS: { key: Tab; label: string; icon: React.ElementType }[] = [ + { key: "bookings", label: "Bookings", icon: BarChart3 }, + { key: "revenue", label: "Revenue", icon: DollarSign }, + { key: "members", label: "Members", icon: Users }, + { key: "occupancy", label: "Occupancy", icon: Building2 }, +]; + +function fmt(kobo: number) { + return `₦${(kobo / 100).toLocaleString("en-NG", { minimumFractionDigits: 2 })}`; +} + +export default function AdminReportsPage() { + const [tab, setTab] = useState("bookings"); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + + const params: Record = {}; + if (from) params.from = new Date(from).toISOString(); + if (to) params.to = new Date(to).toISOString(); + + const { data, isLoading } = useQuery({ + queryKey: ["report", tab, from, to], + queryFn: async () => { + const r = await api.get(`/reports/${tab}`, { params }); + return r.data.data; + }, + }); + + const handleExport = async () => { + const r = await api.get(`/reports/${tab}`, { + params: { ...params, format: "csv" }, + responseType: "blob", + }); + const url = URL.createObjectURL(new Blob([r.data])); + const a = document.createElement("a"); + a.href = url; + a.download = `${tab}-report.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Reports

+ +
+ +
+
+ + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {isLoading ? ( +
Loading…
+ ) : ( + <> + {tab === "bookings" && } + {tab === "revenue" && } + {tab === "members" && } + {tab === "occupancy" && } + + )} +
+ ); +} + +function BookingsTable({ rows }: { rows: any[] }) { + const STATUS_COLORS: Record = { + confirmed: "bg-green-100 text-green-700", + pending: "bg-amber-100 text-amber-700", + cancelled: "bg-red-100 text-red-700", + completed: "bg-blue-100 text-blue-700", + }; + return ( +
+ + + + {["Member", "Workspace", "Start", "End", "Status", "Total"].map((h) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((r) => ( + + + + + + + + + )) + )} + +
+ {h} +
+ No data +
{r.member}{r.workspace}{r.startDate}{r.endDate} + + {r.status} + + {fmt(r.totalKobo)}
+
+ ); +} + +function RevenueView({ data }: { data: any }) { + if (!data) return null; + return ( +
+
+ + + +
+
+ + + + {["Invoice ID", "User", "Amount", "Status", "Date"].map((h) => ( + + ))} + + + + {(data.invoices ?? []).map((inv: any) => ( + + + + + + + + ))} + +
{h}
{inv.id?.slice(0, 8)}{inv.userId?.slice(0, 8)}{fmt(inv.amountKobo)} + + {inv.status} + + + {new Date(inv.createdAt).toLocaleDateString()} +
+
+
+ ); +} + +function MembersView({ data }: { data: any }) { + if (!data) return null; + return ( +
+
+ + +
+
+ + + + {["Name", "Email", "Role", "Status", "Joined"].map((h) => ( + + ))} + + + + {(data.members ?? []).map((m: any) => ( + + + + + + + + ))} + +
{h}
{m.fullName}{m.email}{m.role} + + {m.membershipStatus} + + + {new Date(m.createdAt).toLocaleDateString()} +
+
+
+ ); +} + +function OccupancyTable({ rows }: { rows: any[] }) { + return ( +
+ + + + {["Workspace", "Type", "Bookings in Period"].map((h) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((r) => ( + + + + + + )) + )} + +
{h}
No data
{r.name}{r.type}{r.bookingCount}
+
+ ); +} + +function StatCard({ label, value, color }: { label: string; value: string; color: string }) { + const colors: Record = { + indigo: "bg-indigo-50 text-indigo-700", + green: "bg-green-50 text-green-700", + amber: "bg-amber-50 text-amber-700", + }; + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/frontend/components/dashboard/DashboardSidebar.tsx b/frontend/components/dashboard/DashboardSidebar.tsx index d445c37..d4daa36 100644 --- a/frontend/components/dashboard/DashboardSidebar.tsx +++ b/frontend/components/dashboard/DashboardSidebar.tsx @@ -49,6 +49,7 @@ const adminItems = [ { label: "Members", href: "/admin/members", icon: Users }, { label: "Invoices", href: "/admin/invoices", icon: FileText }, { label: "Newsletter", href: "/dashboard?tab=newsletter", icon: Mail }, + { label: "Reports", href: "/admin/reports", icon: BarChart3 }, { label: "Staff Schedule", href: "/admin/staff", icon: Calendar }, { label: "Facilities", href: "/admin/facilities", icon: MapPin }, ];