Skip to content

Commit

Permalink
feat(APIM-90-91): post and get premium schedules (#45)
Browse files Browse the repository at this point in the history
### Introduction
New endpoint POST /premium/schedule . 
New endpoint GET /premium/segments/{facilityId} . Get previously
generated premium schedules.

###  Resolution
POST /premium/schedule 

- uses SP - USP_MDM_INCOME_EXPOSURE
- generates and returns premium schedules
- returns Location header for GET call

GET  /premium/segments/{facilityId} 

- gets data from table using typeOrm

Allow referencing third party module "express" in .eslintrc.json
Unit test has simple Response mock/fake/stub. It could be improved in
future.

---------

Co-authored-by: Gabriel Ignat <gabriel.ignat@hotmail.com>
  • Loading branch information
avaitonis and IgnatG authored Mar 8, 2023
1 parent 29340ef commit ee4842c
Show file tree
Hide file tree
Showing 12 changed files with 575 additions and 4 deletions.
4 changes: 3 additions & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
"Dockerised",
"CILC",
"BBALIBOR",
"szenius"
"szenius",
"EWCS",
"NVARCHAR"
],
"dictionaries": [
"en-gb",
Expand Down
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
},
"settings": {
"node": {
"allowModules": [
"express"
],
"tryExtensions": [
".js",
".json",
Expand Down
3 changes: 3 additions & 0 deletions src/modules/mdm.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { HealthcheckModule } from '@ukef/module/healthcheck/healthcheck.module';
import { InterestRatesModule } from '@ukef/module/interest-rates/interest-rates.module';
import { MarketsModule } from '@ukef/module/markets/markets.module';
import { NumbersModule } from '@ukef/module/numbers/numbers.module';
import { PremiumSchedulesModule } from '@ukef/module/premium-schedules/premium-schedules.module';
import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-industries.module';

@Module({
Expand All @@ -20,6 +21,7 @@ import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-in
InterestRatesModule,
MarketsModule,
NumbersModule,
PremiumSchedulesModule,
SectorIndustriesModule,
CurrenciesModule,
],
Expand All @@ -32,6 +34,7 @@ import { SectorIndustriesModule } from '@ukef/module/sector-industries/sector-in
InterestRatesModule,
MarketsModule,
NumbersModule,
PremiumSchedulesModule,
SectorIndustriesModule,
CurrenciesModule,
],
Expand Down
6 changes: 3 additions & 3 deletions src/modules/numbers/numbers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ export class NumbersController {
@ApiBody({ type: [CreateUkefIdDto] })
@UsePipes(ValidationPipe)
@ApiResponse({ status: 201, description: 'Created.' })
create(@Body(new ParseArrayPipe({ items: CreateUkefIdDto, optional: false })) CreateUkefIdDtos: CreateUkefIdDto[]): Promise<UkefId[]> {
if (!CreateUkefIdDtos.length) {
create(@Body(new ParseArrayPipe({ items: CreateUkefIdDto, optional: false })) createUkefIdDtos: CreateUkefIdDto[]): Promise<UkefId[]> {
if (!createUkefIdDtos.length) {
throw new BadRequestException('Request payload is empty');
}
return this.numberService.create(CreateUkefIdDtos);
return this.numberService.create(createUkefIdDtos);
}

@Get()
Expand Down
80 changes: 80 additions & 0 deletions src/modules/premium-schedules/dto/create-premium-schedule.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';

export class CreatePremiumScheduleDto {
@IsInt()
@IsNotEmpty()
@ApiProperty({
example: 30000425,
description: 'UKEF id for Facility, but without 00 at beginning. Usually UKEF id is string, but in this endpoint it is number',
})
readonly facilityURN: number;

@IsString()
@IsNotEmpty()
@Length(2)
@Matches(/^(EW|BS)$/)
@ApiProperty({ example: 'BS', description: 'Two products are accepted: EW and BS' })
readonly productGroup: string;

@IsNumber()
@IsNotEmpty()
@Min(1)
@Max(3)
@ApiProperty({
example: 1,
description: 'Premium type concerns how we are being paid. It can be: 1 -> In advance, 2 -> In Arrears or 3-> At Maturity.',
})
readonly premiumTypeId: number;

@IsNumber()
@IsNotEmpty()
@Min(1)
@Max(3)
@ApiProperty({
example: 1,
description: 'Payment frequency. It can be: 1 -> Monthly, 2 -> Quarterly, 3-> Semi-annually or 4 -> Annually',
})
readonly premiumFrequencyId: number;

@IsDateString()
@IsNotEmpty()
@ApiProperty({ example: '2021-01-19', description: 'Start date' })
readonly guaranteeCommencementDate: Date;

@IsDateString()
@IsNotEmpty()
@ApiProperty({ example: '2022-05-17', description: 'End date' })
readonly guaranteeExpiryDate: Date;

@IsNumber()
@IsNotEmpty()
@ApiProperty({ example: 80, description: 'Percentage covered, expecting whole number i.e. if 90% expecting the number 90' })
readonly guaranteePercentage: number;

@IsNumber()
@IsNotEmpty()
@ApiProperty({ example: 1.35, description: 'UKEF Fee percentage, expecting whole number i.e. if 90% expecting the number 90' })
readonly guaranteeFeePercentage: number;

@IsString()
@IsNotEmpty()
@Length(3)
@ApiProperty({ example: '360', description: '360 or 365. UK or US calendar' })
readonly dayBasis: string;

@IsNumber()
@IsNotEmpty()
@ApiProperty({ example: 16, description: 'How many periods are we exposed to the risk, This is pre-calculated in the Exposure Period Calc' })
readonly exposurePeriod: number;

@IsNumber()
@IsOptional()
@ApiProperty({ example: null, description: 'Optional EWCS Exposure ONLY, this is the cumulative amount drawn on the first disbursement. NULL if not EWCS' })
readonly cumulativeAmount: number;

@IsNumber()
@IsNotEmpty()
@ApiProperty({ example: 40000, description: 'Required for BS Exposure' })
readonly maximumLiability: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';

export class GetPremiumScheduleParamDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: '30000425',
description: 'UKEF id for Facility, but without 00 at beginning',
})
@Matches(/^\d{8,10}$/)
public facilityId: string;
}
65 changes: 65 additions & 0 deletions src/modules/premium-schedules/entities/premium-schedule.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { ClassSerializerInterceptor, UseInterceptors } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, PrimaryColumn } from 'typeorm';

@Entity({
name: 'INCOME_EXPOSURE',
schema: 'dbo',
})
@UseInterceptors(ClassSerializerInterceptor)
export class PremiumScheduleEntity {
@PrimaryColumn({ name: 'D_INCOME_EXPOSURE_ID' })
id: number;

@Column({ name: 'FACILITY_ID' })
@ApiProperty({
example: '30000425',
description: 'UKEF id for Facility, but without 00 at beginning',
})
facilityURN: string;

// Date only.
@Column({ name: 'CALCULATION_DATE', type: 'date' })
@ApiProperty({ example: '2023-02-27' })
calculationDate: Date;

@Column({ name: 'INCOME', type: 'decimal' })
@ApiProperty({ example: 465.0 })
income: number;

@Column({ name: 'INCOME_PER_DAY', type: 'decimal' })
@ApiProperty({ example: 15.0 })
incomePerDay: number;

@Column({ name: 'EXPOSURE', type: 'decimal' })
@ApiProperty({ example: 400000.0 })
exposure: number;

@Column({ name: 'PERIOD' })
@ApiProperty({ example: 1 })
period: number;

@Column({ name: 'DAYS_IN_PERIOD' })
@ApiProperty({ example: 31 })
daysInPeriod: number;

@Column({ name: 'EFFECTIVE_FROM_DATETIME', type: 'timestamp' })
@ApiProperty({ example: '2023-02-27 00:00:00.000' })
effectiveFrom: Date;

@Column({ name: 'EFFECTIVE_TO_DATETIME', type: 'timestamp' })
@ApiProperty({ example: '2024-02-27 00:00:00.000' })
effectiveTo: string;

@Column({ name: 'DATE_CREATED_DATETIME', type: 'timestamp' })
@ApiProperty({ example: '2023-02-27 00:00:00.000' })
created: Date;

@Column({ name: 'DATE_LAST_UPDATED_DATETIME', type: 'timestamp' })
@ApiProperty({ example: '2023-02-27 00:00:00.000' })
updated: Date;

@Column({ name: 'CURRENT_INDICATOR' })
@ApiProperty({ example: 'Y', description: 'Can be Y or N. Not active records are just for record tracking. Just active records will be returned.' })
isActive: string;
}
76 changes: 76 additions & 0 deletions src/modules/premium-schedules/premium-schedules.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Test, TestingModule } from '@nestjs/testing';
import Chance from 'chance';
import { Response } from 'express';

import { CreatePremiumScheduleDto } from './dto/create-premium-schedule.dto';
import { PremiumSchedulesController } from './premium-schedules.controller';
import { PremiumSchedulesService } from './premium-schedules.service';

const chance = new Chance();

// Minimal mock of express Response. "as any" is required to get around Typescript type check.
// TODO: this can be rewritten to use mock library.
const mockResponseObject = {
set: jest.fn().mockReturnValue({}),
} as any as Response;

describe('PremiumSchedulesController', () => {
let premiumSchedulesController: PremiumSchedulesController;
let premiumSchedulesService: PremiumSchedulesService;

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [PremiumSchedulesController],
providers: [
PremiumSchedulesService,
{
provide: PremiumSchedulesService,
useValue: {
find: jest.fn().mockResolvedValue([
{
id: chance.natural(),
facilityURN: chance.natural({ min: 10000000, max: 99999999 }).toString(),
calculationDate: chance.word(),
income: chance.natural(),
incomePerDay: chance.word(),
exposure: chance.currency().code,
period: chance.natural(),
daysInPeriod: chance.word(),
effectiveFrom: chance.date({ string: true }),
effectiveTo: chance.date({ string: true }),
created: chance.date({ string: true }),
updated: chance.date({ string: true }),
isActive: 'Y',
},
]),
create: jest.fn().mockResolvedValue({}),
},
},
],
}).compile();

premiumSchedulesController = app.get<PremiumSchedulesController>(PremiumSchedulesController);
premiumSchedulesService = app.get<PremiumSchedulesService>(PremiumSchedulesService);
});

it('should be defined', () => {
expect(PremiumSchedulesController).toBeDefined();
});

describe('create()', () => {
it('should create schedule rates', () => {
const createSchedules = new CreatePremiumScheduleDto();
premiumSchedulesController.create(mockResponseObject, [createSchedules]);

expect(premiumSchedulesService.create).toHaveBeenCalled();
});
});

describe('find()', () => {
it('should find premium schedules for Facility id', () => {
premiumSchedulesController.find({ facilityId: chance.natural({ min: 10000000, max: 99999999 }).toString() });

expect(premiumSchedulesService.find).toHaveBeenCalled();
});
});
});
46 changes: 46 additions & 0 deletions src/modules/premium-schedules/premium-schedules.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { BadRequestException, Body, Controller, Get, Param, ParseArrayPipe, Post, Res } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';

import { CreatePremiumScheduleDto } from './dto/create-premium-schedule.dto';
import { GetPremiumScheduleParamDto } from './dto/get-premium-schedule-param.dto';
import { PremiumScheduleEntity } from './entities/premium-schedule.entity';
import { PremiumSchedulesService } from './premium-schedules.service';

@ApiTags('premium-schedules')
@Controller('')
export class PremiumSchedulesController {
constructor(private readonly premiumSchedulesService: PremiumSchedulesService) {}

@Post('premium/schedule')
@ApiOperation({ summary: 'Create Premium Schedule sequence (aka Income exposure)' })
@ApiBody({ type: [CreatePremiumScheduleDto] })
@ApiResponse({ status: 201, description: 'Created.' })
create(
@Res({ passthrough: true }) res: Response,
@Body(new ParseArrayPipe({ items: CreatePremiumScheduleDto, optional: false })) createPremiumSchedule: CreatePremiumScheduleDto[],
) {
if (!createPremiumSchedule.length) {
throw new BadRequestException('Request payload is empty');
}

return this.premiumSchedulesService.create(res, createPremiumSchedule[0]);
}

@Get('premium/segments/:facilityId')
@ApiOperation({ summary: 'Return previously generated Premium Schedule sequence/segments (aka Income exposures)' })
@ApiResponse({
status: 200,
type: [PremiumScheduleEntity],
})
@ApiParam({
name: 'facilityId',
required: true,
type: 'string',
description: 'UKEF facility id',
example: '10588388',
})
find(@Param() param: GetPremiumScheduleParamDto): Promise<PremiumScheduleEntity[]> {
return this.premiumSchedulesService.find(param.facilityId);
}
}
14 changes: 14 additions & 0 deletions src/modules/premium-schedules/premium-schedules.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 { DATABASE } from '@ukef/constants';

import { PremiumScheduleEntity } from './entities/premium-schedule.entity';
import { PremiumSchedulesController } from './premium-schedules.controller';
import { PremiumSchedulesService } from './premium-schedules.service';

@Module({
imports: [TypeOrmModule.forFeature([PremiumScheduleEntity], DATABASE.MDM)],
controllers: [PremiumSchedulesController],
providers: [PremiumSchedulesService],
})
export class PremiumSchedulesModule {}
Loading

0 comments on commit ee4842c

Please sign in to comment.