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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -127,6 +129,8 @@ import { TeamsModule } from './teams/teams.module';
WaitlistModule,
EventsModule,
MembershipPlansModule,
BillingModule,
DunningModule,
ShiftsModule,
FloorPlanModule,
ReportsModule,
Expand Down
30 changes: 30 additions & 0 deletions backend/src/billing/billing.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
14 changes: 14 additions & 0 deletions backend/src/billing/billing.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
91 changes: 91 additions & 0 deletions backend/src/billing/billing.service.ts
Original file line number Diff line number Diff line change
@@ -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<BillingCycle>,
@InjectRepository(Booking)
private readonly bookingRepo: Repository<Booking>,
) {}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async generateMonthlyBillingCycles(): Promise<void> {
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<BillingCycle[]> {
const where: any = {};
if (userId) where.userId = userId;
return this.cycleRepo.find({ where, order: { createdAt: 'DESC' } });
}

async findPendingForRetry(): Promise<BillingCycle[]> {
return this.cycleRepo.find({
where: {
status: BillingCycleStatus.FAILED,
nextRetryAt: LessThanOrEqual(new Date()),
},
});
}

async markFailed(id: string, reason: string, retryAt: Date): Promise<void> {
await this.cycleRepo.update(id, {
status: BillingCycleStatus.FAILED,
failureReason: reason,
nextRetryAt: retryAt,
});
}

async markPaid(id: string, invoiceId: string): Promise<void> {
await this.cycleRepo.update(id, {
status: BillingCycleStatus.PAID,
invoiceId,
nextRetryAt: null,
failureReason: null,
});
}

async incrementRetry(id: string): Promise<void> {
const cycle = await this.cycleRepo.findOneOrFail({ where: { id } });
await this.cycleRepo.update(id, { retryCount: cycle.retryCount + 1 });
}
}
57 changes: 57 additions & 0 deletions backend/src/billing/entities/billing-cycle.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions backend/src/dunning/dunning.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
50 changes: 50 additions & 0 deletions backend/src/dunning/dunning.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`,
);
}
}
}
}
13 changes: 13 additions & 0 deletions backend/src/promo-codes/dto/apply-promo-code.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions backend/src/promo-codes/promo-codes.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
3 changes: 2 additions & 1 deletion backend/src/promo-codes/promo-codes.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
27 changes: 27 additions & 0 deletions backend/src/promo-codes/promo-codes.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
Expand All @@ -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;
Expand All @@ -30,9 +33,33 @@ export class PromoCodesService {
private readonly usagesRepository: Repository<PromoCodeUsage>,
@InjectRepository(Workspace)
private readonly workspacesRepository: Repository<Workspace>,
@InjectRepository(Booking)
private readonly bookingRepository: Repository<Booking>,
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<PromoCode> {
const code = dto.code.toUpperCase();
const existing = await this.promoCodesRepository.findOne({
Expand Down
Loading
Loading