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/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,7 @@ STELLAR_HORIZON_URL=https://soroban-testnet.stellar.org
# Scheduled Jobs
# Minutes a PENDING booking may wait for payment before it is auto-cancelled
BOOKING_PAYMENT_TTL_MINUTES=120

# Payments module (issue #1570)
# Minutes an INITIATED Payment may sit before it is eligible for expiry sweep
PAYMENT_INITIATED_TTL_MINUTES=30
Empty file removed backend/src/.gitkeep
Empty file.
12 changes: 12 additions & 0 deletions backend/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get('health')
getHealth(): { status: string } {
return this.appService.getHealth();
}
}
31 changes: 31 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { PaymentsModule } from './payments/payments.module';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get<string>('DATABASE_HOST'),
port: config.get<number>('DATABASE_PORT', 5432),
username: config.get<string>('DATABASE_USERNAME'),
password: config.get<string>('DATABASE_PASSWORD'),
database: config.get<string>('DATABASE_NAME'),
autoLoadEntities: true,
synchronize: false,
}),
}),
AuthModule,
PaymentsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
8 changes: 8 additions & 0 deletions backend/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
getHealth(): { status: string } {
return { status: 'ok' };
}
}
21 changes: 21 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';

@Global()
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET'),
}),
}),
],
providers: [JwtAuthGuard, RolesGuard],
exports: [JwtModule, JwtAuthGuard, RolesGuard],
})
export class AuthModule {}
12 changes: 12 additions & 0 deletions backend/src/auth/decorators/current-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import {
AuthenticatedRequest,
RequestUser,
} from '../interfaces/authenticated-request.interface';

export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): RequestUser => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
return request.user;
},
);
5 changes: 5 additions & 0 deletions backend/src/auth/decorators/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../enums/user-role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
4 changes: 4 additions & 0 deletions backend/src/auth/enums/user-role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
}
43 changes: 43 additions & 0 deletions backend/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {
AuthenticatedRequest,
RequestUser,
} from '../interfaces/authenticated-request.interface';

interface JwtPayload {
sub: string;
role: RequestUser['role'];
}

@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request);

if (!token) {
throw new UnauthorizedException('Missing bearer token');
}

try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
request.user = { id: payload.sub, role: payload.role };
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}

private extractToken(request: AuthenticatedRequest): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
24 changes: 24 additions & 0 deletions backend/src/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../interfaces/authenticated-request.interface';
import { UserRole } from '../enums/user-role.enum';

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);

if (!requiredRoles || requiredRoles.length === 0) {
return true;
}

const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return requiredRoles.includes(request.user?.role);
}
}
11 changes: 11 additions & 0 deletions backend/src/auth/interfaces/authenticated-request.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Request } from 'express';
import { UserRole } from '../enums/user-role.enum';

export interface RequestUser {
id: string;
role: UserRole;
}

export interface AuthenticatedRequest extends Request {
user: RequestUser;
}
18 changes: 18 additions & 0 deletions backend/src/database/data-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'dotenv/config';
import { DataSource } from 'typeorm';

export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT
? parseInt(process.env.DATABASE_PORT, 10)
: 5432,
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
});

export default AppDataSource;
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreatePaymentsTable1755784074000 implements MigrationInterface {
name = 'CreatePaymentsTable1755784074000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE "payments_rail_enum" AS ENUM (
'FIAT', 'STELLAR_CUSTODIAL', 'STELLAR_EXTERNAL'
)
`);

await queryRunner.query(`
CREATE TYPE "payments_status_enum" AS ENUM (
'INITIATED', 'AWAITING_CONFIRMATION', 'CONFIRMED',
'FAILED', 'EXPIRED', 'REFUNDED', 'PARTIALLY_REFUNDED'
)
`);

await queryRunner.query(`
CREATE TABLE "payments" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"booking_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"amount" bigint NOT NULL,
"currency" varchar(3) NOT NULL,
"rail" "payments_rail_enum" NOT NULL,
"provider" varchar,
"provider_reference" varchar,
"status" "payments_status_enum" NOT NULL DEFAULT 'INITIATED',
"idempotency_key" varchar NOT NULL,
"metadata" jsonb,
"expires_at" timestamptz,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "pk_payments" PRIMARY KEY ("id")
)
`);

await queryRunner.query(`
CREATE INDEX "idx_payments_booking_id" ON "payments" ("booking_id")
`);

await queryRunner.query(`
CREATE INDEX "idx_payments_user_id" ON "payments" ("user_id")
`);

// Idempotency: a retried request (same user, same key) must resolve to
// the same row.
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_payments_user_id_idempotency_key"
ON "payments" ("user_id", "idempotency_key")
`);

// Concurrency safety: at most one non-terminal Payment per booking,
// independent of idempotency key, so two different initiate requests
// racing for the same booking can't both create a row.
await queryRunner.query(`
CREATE UNIQUE INDEX "uq_payments_booking_id_non_terminal"
ON "payments" ("booking_id")
WHERE "status" IN ('INITIATED', 'AWAITING_CONFIRMATION')
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "uq_payments_booking_id_non_terminal"`);
await queryRunner.query(`DROP INDEX "uq_payments_user_id_idempotency_key"`);
await queryRunner.query(`DROP INDEX "idx_payments_user_id"`);
await queryRunner.query(`DROP INDEX "idx_payments_booking_id"`);
await queryRunner.query(`DROP TABLE "payments"`);
await queryRunner.query(`DROP TYPE "payments_status_enum"`);
await queryRunner.query(`DROP TYPE "payments_rail_enum"`);
}
}
35 changes: 35 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);

const config = new DocumentBuilder()
.setTitle('ManageHub API')
.setDescription(
'ManageHub backend API.\n\n' +
'## Payment state machine\n' +
'INITIATED -> AWAITING_CONFIRMATION -> CONFIRMED | FAILED | EXPIRED\n' +
'CONFIRMED -> REFUNDED | PARTIALLY_REFUNDED\n' +
'All transitions are enforced by a single guarded service method.',
)
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);

const port = process.env.PORT ?? 6000;
await app.listen(port);
}
bootstrap();
21 changes: 21 additions & 0 deletions backend/src/payments/adapters/sandbox-rail.adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Payment } from '../entities/payment.entity';
import {
PaymentInitiationResult,
PaymentRailAdapter,
} from '../interfaces/payment-rail-adapter.interface';

/**
* Placeholder adapter used until the real Paystack / Stellar adapters land
* (issues 2-7 of the payment track). Never wired to a live provider.
*/
@Injectable()
export class SandboxRailAdapter implements PaymentRailAdapter {
async initiate(payment: Payment): Promise<PaymentInitiationResult> {
return {
providerReference: `sandbox_${randomUUID()}`,
metadata: { sandbox: true, bookingId: payment.bookingId },
};
}
}
47 changes: 47 additions & 0 deletions backend/src/payments/dto/initiate-payment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsInt,
IsObject,
IsOptional,
IsPositive,
IsString,
IsUUID,
Length,
} from 'class-validator';
import { PaymentRail } from '../enums/payment-rail.enum';

export class InitiatePaymentDto {
@ApiProperty({ description: 'Booking (or order) this payment is for' })
@IsUUID()
bookingId: string;

@ApiProperty({
description: 'Amount in minor units (e.g. cents)',
example: 5000,
})
@IsInt()
@IsPositive()
amount: number;

@ApiProperty({ description: 'ISO 4217 currency code', example: 'USD' })
@IsString()
@Length(3, 3)
currency: string;

@ApiProperty({ enum: PaymentRail })
@IsEnum(PaymentRail)
rail: PaymentRail;

@ApiPropertyOptional({
description: 'Payment provider identifier, e.g. paystack',
})
@IsOptional()
@IsString()
provider?: string;

@ApiPropertyOptional({ type: 'object' })
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
Loading
Loading