diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml
index a8441e7ff..766b9bfdd 100644
--- a/.github/workflows/stage-3-build.yaml
+++ b/.github/workflows/stage-3-build.yaml
@@ -161,7 +161,8 @@ jobs:
--targetAccountGroup "nhs-notify-supplier-api-dev" \
--terraformAction "apply" \
--overrideProjectName "nhs" \
- --overrideRoleName "nhs-main-acct-supplier-api-github-deploy"
+ --overrideRoleName "nhs-main-acct-supplier-api-github-deploy" \
+ --internalRef "feature/CCM-15148"
artefact-proxies:
name: "Build proxies"
runs-on: ubuntu-latest
diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index f1f278c5e..0e6b2dbec 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -34,7 +34,7 @@ No requirements.
| [group](#input\_group) | The group variables are being inherited from (often synonmous with account short-name) | `string` | n/a | yes |
| [kms\_deletion\_window](#input\_kms\_deletion\_window) | When a kms key is deleted, how long should it wait in the pending deletion state? | `string` | `"30"` | no |
| [letter\_table\_ttl\_hours](#input\_letter\_table\_ttl\_hours) | Number of hours to set as TTL on letters table | `number` | `24` | no |
-| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string }))` |
{
"lv1": {
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"specId": "spec3",
"supplierId": "supplier2"
}
} | no |
+| [letter\_variant\_map](#input\_letter\_variant\_map) | n/a | `map(object({ supplierId = string, specId = string, billingId = string }))` | {
"lv1": {
"billingId": "billing1",
"specId": "spec1",
"supplierId": "supplier1"
},
"lv2": {
"billingId": "billing2",
"specId": "spec2",
"supplierId": "supplier1"
},
"lv3": {
"billingId": "billing3",
"specId": "spec3",
"supplierId": "supplier2"
}
} | no |
| [log\_level](#input\_log\_level) | The log level to be used in lambda functions within the component. Any log with a lower severity than the configured value will not be logged: https://docs.python.org/3/library/logging.html#levels | `string` | `"INFO"` | no |
| [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The retention period in days for the Cloudwatch Logs events to be retained, default of 0 is indefinite | `number` | `0` | no |
| [manually\_configure\_mtls\_truststore](#input\_manually\_configure\_mtls\_truststore) | Manually manage the truststore used for API Gateway mTLS (e.g. for prod environment) | `bool` | `false` | no |
diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf
new file mode 100644
index 000000000..8d089a19b
--- /dev/null
+++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf
@@ -0,0 +1,59 @@
+resource "aws_dynamodb_table" "supplier-configuration" {
+ name = "${local.csi}-supplier-config"
+ billing_mode = "PAY_PER_REQUEST"
+
+ hash_key = "PK"
+ range_key = "SK"
+
+ ttl {
+ attribute_name = "ttl"
+ enabled = true
+ }
+
+ attribute {
+ name = "PK"
+ type = "S"
+ }
+
+ attribute {
+ name = "SK"
+ type = "S"
+ }
+
+ attribute {
+ name = "entityType"
+ type = "S"
+ }
+
+ attribute {
+ name = "volumeGroup"
+ type = "S"
+ }
+
+ // The type-index GSI allows us to query for all supplier configurations of a given type (e.g. all letter supplier configurations)
+ global_secondary_index {
+ name = "EntityTypeIndex"
+ hash_key = "entityType"
+ range_key = "SK"
+ projection_type = "ALL"
+ }
+
+ global_secondary_index {
+ name = "volumeGroup-index"
+ hash_key = "PK"
+ range_key = "volumeGroup"
+ projection_type = "ALL"
+ }
+
+ point_in_time_recovery {
+ enabled = true
+ }
+
+ tags = merge(
+ local.default_tags,
+ {
+ NHSE-Enable-Dynamo-Backup-Acct = "True"
+ }
+ )
+
+}
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 46c7aca72..8edd8fa80 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -20,15 +20,16 @@ locals {
destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs"
common_lambda_env_vars = {
- LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
- MI_TABLE_NAME = aws_dynamodb_table.mi.name,
- LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
- MI_TTL_HOURS = 2160 # 90 days * 24 hours
- SUPPLIER_ID_HEADER = "nhsd-supplier-id",
- APIM_CORRELATION_HEADER = "nhsd-correlation-id",
- DOWNLOAD_URL_TTL_SECONDS = 60
- SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
- EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
+ LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
+ MI_TABLE_NAME = aws_dynamodb_table.mi.name,
+ LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
+ MI_TTL_HOURS = 2160 # 90 days * 24 hours
+ SUPPLIER_ID_HEADER = "nhsd-supplier-id",
+ APIM_CORRELATION_HEADER = "nhsd-correlation-id",
+ DOWNLOAD_URL_TTL_SECONDS = 60
+ SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
+ EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
+ SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name
}
core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline"
diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf
index 76cfb22a6..b568307c9 100644
--- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf
+++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf
@@ -82,4 +82,20 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" {
module.sqs_letter_updates.sqs_queue_arn
]
}
+
+ statement {
+ sid = "AllowDynamoDBAccess"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:GetItem",
+ "dynamodb:Query"
+ ]
+
+ resources = [
+ aws_dynamodb_table.supplier-configuration.arn,
+ "${aws_dynamodb_table.supplier-configuration.arn}/index/volumeGroup-index"
+
+ ]
+ }
}
diff --git a/infrastructure/terraform/components/api/variables.tf b/infrastructure/terraform/components/api/variables.tf
index 97169278e..d7faa9a81 100644
--- a/infrastructure/terraform/components/api/variables.tf
+++ b/infrastructure/terraform/components/api/variables.tf
@@ -136,11 +136,11 @@ variable "eventpub_control_plane_bus_arn" {
}
variable "letter_variant_map" {
- type = map(object({ supplierId = string, specId = string }))
+ type = map(object({ supplierId = string, specId = string, billingId = string }))
default = {
- "lv1" = { supplierId = "supplier1", specId = "spec1" },
- "lv2" = { supplierId = "supplier1", specId = "spec2" },
- "lv3" = { supplierId = "supplier2", specId = "spec3" }
+ "lv1" = { supplierId = "supplier1", specId = "spec1", billingId = "billing1" },
+ "lv2" = { supplierId = "supplier1", specId = "spec2", billingId = "billing2" },
+ "lv3" = { supplierId = "supplier2", specId = "spec3", billingId = "billing3" }
}
}
diff --git a/internal/datastore/jest.config.ts b/internal/datastore/jest.config.ts
index 1fb73e6d1..da6c0a863 100644
--- a/internal/datastore/jest.config.ts
+++ b/internal/datastore/jest.config.ts
@@ -31,6 +31,9 @@ export const baseJestConfig: Config = {
coveragePathIgnorePatterns: ["/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
diff --git a/internal/datastore/package.json b/internal/datastore/package.json
index 23531375c..f3e56167f 100644
--- a/internal/datastore/package.json
+++ b/internal/datastore/package.json
@@ -3,6 +3,7 @@
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/lib-dynamodb": "^3.984.0",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1",
"pino": "^10.3.0",
"zod": "^4.1.11",
"zod-mermaid": "^1.0.9"
diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts
index f382add62..8f5e224bd 100644
--- a/internal/datastore/src/__test__/db.ts
+++ b/internal/datastore/src/__test__/db.ts
@@ -36,6 +36,7 @@ export async function setupDynamoDBContainer() {
lettersTtlHours: 1,
letterQueueTtlHours: 1,
miTtlHours: 1,
+ supplierConfigTableName: "supplier-config",
};
return {
@@ -145,6 +146,31 @@ const createLetterQueueTableCommand = new CreateTableCommand({
{ AttributeName: "queueTimestamp", AttributeType: "S" },
],
});
+const createSupplierConfigTableCommand = new CreateTableCommand({
+ TableName: "supplier-config",
+ BillingMode: "PAY_PER_REQUEST",
+ KeySchema: [
+ { AttributeName: "PK", KeyType: "HASH" }, // Partition key
+ { AttributeName: "SK", KeyType: "RANGE" }, // Sort key
+ ],
+ GlobalSecondaryIndexes: [
+ {
+ IndexName: "volumeGroup-index",
+ KeySchema: [
+ { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI
+ { AttributeName: "volumeGroup", KeyType: "RANGE" }, // Sort key for GSI
+ ],
+ Projection: {
+ ProjectionType: "ALL",
+ },
+ },
+ ],
+ AttributeDefinitions: [
+ { AttributeName: "PK", AttributeType: "S" },
+ { AttributeName: "SK", AttributeType: "S" },
+ { AttributeName: "volumeGroup", AttributeType: "S" },
+ ],
+});
export async function createTables(context: DBContext) {
const { ddbClient } = context;
@@ -155,6 +181,7 @@ export async function createTables(context: DBContext) {
await ddbClient.send(createMITableCommand);
await ddbClient.send(createSupplierTableCommand);
await ddbClient.send(createLetterQueueTableCommand);
+ await ddbClient.send(createSupplierConfigTableCommand);
}
export async function deleteTables(context: DBContext) {
@@ -165,6 +192,7 @@ export async function deleteTables(context: DBContext) {
"management-info",
"suppliers",
"letter-queue",
+ "supplier-config",
]) {
await ddbClient.send(
new DeleteTableCommand({
diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts
index 193c1c077..0f6149717 100644
--- a/internal/datastore/src/__test__/letter-repository.test.ts
+++ b/internal/datastore/src/__test__/letter-repository.test.ts
@@ -30,6 +30,7 @@ function createLetter(
source: "/data-plane/letter-rendering/pdf",
subject: `client/1/letter-request/${letterId}`,
billingRef: "specification1",
+ specificationBillingId: "billing1",
};
}
diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts
new file mode 100644
index 000000000..9fde74f94
--- /dev/null
+++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts
@@ -0,0 +1,266 @@
+import { PutCommand } from "@aws-sdk/lib-dynamodb";
+import {
+ DBContext,
+ createTables,
+ deleteTables,
+ setupDynamoDBContainer,
+} from "./db";
+import { SupplierConfigRepository } from "../supplier-config-repository";
+
+function createLetterVariantItem(variantId: string) {
+ return {
+ PK: "LETTER_VARIANT",
+ SK: variantId,
+ id: variantId,
+ name: `Variant ${variantId}`,
+ description: `Description for variant ${variantId}`,
+ type: "STANDARD",
+ status: "PROD",
+ volumeGroupId: `group-${variantId}`,
+ packSpecificationIds: [`pack-spec-${variantId}`],
+ };
+}
+
+function createVolumeGroupItem(groupId: string, status = "PROD") {
+ const startDate = new Date(Date.now() - 24 * 1000 * 60 * 60)
+ .toISOString()
+ .split("T")[0]; // Started a day ago to ensure it's active based on start date. Tests can override this if needed.
+ const endDate = new Date(Date.now() + 24 * 1000 * 60 * 60)
+ .toISOString()
+ .split("T")[0]; // Ends in a day to ensure it's active based on end date. Tests can override this if needed.
+ return {
+ PK: "VOLUME_GROUP",
+ SK: groupId,
+ id: groupId,
+ name: `Volume Group ${groupId}`,
+ description: `Description for volume group ${groupId}`,
+ status,
+ startDate,
+ endDate,
+ };
+}
+
+function createSupplierAllocationItem(
+ allocationId: string,
+ groupId: string,
+ supplier: string,
+) {
+ return {
+ PK: `SUPPLIER_ALLOCATION`,
+ SK: allocationId,
+ id: allocationId,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier,
+ allocationPercentage: 50,
+ };
+}
+
+function createSupplierItem(supplierId: string) {
+ return {
+ PK: "SUPPLIER",
+ SK: supplierId,
+ id: supplierId,
+ name: `Supplier ${supplierId}`,
+ channelType: "LETTER",
+ dailyCapacity: 1000,
+ status: "PROD",
+ };
+}
+
+jest.setTimeout(30_000);
+
+describe("SupplierConfigRepository", () => {
+ let dbContext: DBContext;
+ let repository: SupplierConfigRepository;
+
+ // Database tests can take longer, especially with setup and teardown
+ beforeAll(async () => {
+ dbContext = await setupDynamoDBContainer();
+ });
+
+ beforeEach(async () => {
+ await createTables(dbContext);
+ repository = new SupplierConfigRepository(
+ dbContext.docClient,
+ dbContext.config,
+ );
+ });
+
+ afterEach(async () => {
+ await deleteTables(dbContext);
+ jest.useRealTimers();
+ });
+
+ afterAll(async () => {
+ await dbContext.container.stop();
+ });
+
+ test("getLetterVariant returns correct details for existing variant", async () => {
+ const variantId = "variant-123";
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createLetterVariantItem(variantId),
+ }),
+ );
+
+ const result = await repository.getLetterVariant(variantId);
+
+ expect(result.id).toBe(variantId);
+ expect(result.name).toBe(`Variant ${variantId}`);
+ expect(result.description).toBe(`Description for variant ${variantId}`);
+ expect(result.type).toBe("STANDARD");
+ expect(result.status).toBe("PROD");
+ expect(result.volumeGroupId).toBe(`group-${variantId}`);
+ expect(result.packSpecificationIds).toEqual([`pack-spec-${variantId}`]);
+ });
+
+ test("getLetterVariant throws error for non-existent variant", async () => {
+ const variantId = "non-existent-variant";
+
+ await expect(repository.getLetterVariant(variantId)).rejects.toThrow(
+ `No letter variant details found for id ${variantId}`,
+ );
+ });
+
+ test("getVolumeGroup returns correct details for existing group", async () => {
+ const groupId = "group-123";
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createVolumeGroupItem(groupId),
+ }),
+ );
+
+ const result = await repository.getVolumeGroup(groupId);
+
+ expect(result.id).toBe(groupId);
+ expect(result.name).toBe(`Volume Group ${groupId}`);
+ expect(result.description).toBe(`Description for volume group ${groupId}`);
+ expect(result.status).toBe("PROD");
+ expect(new Date(result.startDate).getTime()).toBeLessThan(Date.now());
+ expect(new Date(result.endDate!).getTime()).toBeGreaterThan(Date.now());
+ });
+
+ test("getVolumeGroup throws error for non-existent group", async () => {
+ const groupId = "non-existent-group";
+
+ await expect(repository.getVolumeGroup(groupId)).rejects.toThrow(
+ `No volume group details found for id ${groupId}`,
+ );
+ });
+
+ test("getSupplierAllocationsForVolumeGroup returns correct allocations", async () => {
+ const allocationId = "allocation-123";
+ const groupId = "group-123";
+ const supplierId = "supplier-123";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createSupplierAllocationItem(allocationId, groupId, supplierId),
+ }),
+ );
+
+ const result =
+ await repository.getSupplierAllocationsForVolumeGroup(groupId);
+
+ expect(result).toEqual([
+ {
+ id: allocationId,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier: supplierId,
+ allocationPercentage: 50,
+ },
+ ]);
+ });
+
+ test("getSupplierAllocationsForVolumeGroup returns multiple allocations", async () => {
+ const allocationId1 = "allocation-123";
+ const allocationId2 = "allocation-456";
+ const groupId = "group-123";
+ const supplierId1 = "supplier-123";
+ const supplierId2 = "supplier-456";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createSupplierAllocationItem(allocationId1, groupId, supplierId1),
+ }),
+ );
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createSupplierAllocationItem(allocationId2, groupId, supplierId2),
+ }),
+ );
+
+ const result =
+ await repository.getSupplierAllocationsForVolumeGroup(groupId);
+
+ // order of allocations should not matter, just that both are present
+ expect(result).toHaveLength(2);
+ expect(result).toEqual(
+ expect.arrayContaining([
+ {
+ id: allocationId1,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier: supplierId1,
+ allocationPercentage: 50,
+ },
+ {
+ id: allocationId2,
+ status: "PROD",
+ volumeGroup: groupId,
+ supplier: supplierId2,
+ allocationPercentage: 50,
+ },
+ ]),
+ );
+ });
+
+ test("getSupplierAllocationsForVolumeGroup throws error for non-existent group", async () => {
+ const groupId = "non-existent-group";
+
+ await expect(
+ repository.getSupplierAllocationsForVolumeGroup(groupId),
+ ).rejects.toThrow(
+ `No active supplier allocations found for volume group id ${groupId}`,
+ );
+ });
+
+ test("getSuppliersDetails returns correct supplier details", async () => {
+ const supplierId = "supplier-123";
+
+ await dbContext.docClient.send(
+ new PutCommand({
+ TableName: dbContext.config.supplierConfigTableName,
+ Item: createSupplierItem(supplierId),
+ }),
+ );
+
+ const result = await repository.getSuppliersDetails([supplierId]);
+
+ expect(result).toEqual([
+ {
+ id: supplierId,
+ name: `Supplier ${supplierId}`,
+ channelType: "LETTER",
+ dailyCapacity: 1000,
+ status: "PROD",
+ },
+ ]);
+ });
+
+ test("getSuppliersDetails throws error for non-existent supplier", async () => {
+ const supplierId = "non-existent-supplier";
+
+ await expect(repository.getSuppliersDetails([supplierId])).rejects.toThrow(
+ `Supplier with id ${supplierId} not found`,
+ );
+ });
+});
diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts
index 4066101ad..f07954028 100644
--- a/internal/datastore/src/config.ts
+++ b/internal/datastore/src/config.ts
@@ -8,4 +8,5 @@ export type DatastoreConfig = {
lettersTtlHours: number;
letterQueueTtlHours: number;
miTtlHours: number;
+ supplierConfigTableName: string;
};
diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts
index 7ee912c23..6c32e4bc3 100644
--- a/internal/datastore/src/index.ts
+++ b/internal/datastore/src/index.ts
@@ -3,5 +3,6 @@ export * from "./errors";
export * from "./mi-repository";
export * from "./letter-repository";
export * from "./supplier-repository";
+export * from "./supplier-config-repository";
export { default as LetterQueueRepository } from "./letter-queue-repository";
export { default as DBHealthcheck } from "./healthcheck";
diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts
new file mode 100644
index 000000000..1f82bb0c2
--- /dev/null
+++ b/internal/datastore/src/supplier-config-repository.ts
@@ -0,0 +1,100 @@
+import {
+ DynamoDBDocumentClient,
+ GetCommand,
+ QueryCommand,
+} from "@aws-sdk/lib-dynamodb";
+import {
+ $LetterVariant,
+ $Supplier,
+ $SupplierAllocation,
+ $VolumeGroup,
+ LetterVariant,
+ Supplier,
+ SupplierAllocation,
+ VolumeGroup,
+} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
+
+export type SupplierConfigRepositoryConfig = {
+ supplierConfigTableName: string;
+};
+
+export class SupplierConfigRepository {
+ constructor(
+ readonly ddbClient: DynamoDBDocumentClient,
+ readonly config: SupplierConfigRepositoryConfig,
+ ) {}
+
+ async getLetterVariant(variantId: string): Promise {
+ const result = await this.ddbClient.send(
+ new GetCommand({
+ TableName: this.config.supplierConfigTableName,
+ Key: { PK: "LETTER_VARIANT", SK: variantId },
+ }),
+ );
+ if (!result.Item) {
+ throw new Error(`No letter variant details found for id ${variantId}`);
+ }
+
+ return $LetterVariant.parse(result.Item);
+ }
+
+ async getVolumeGroup(groupId: string): Promise {
+ const result = await this.ddbClient.send(
+ new GetCommand({
+ TableName: this.config.supplierConfigTableName,
+ Key: { PK: "VOLUME_GROUP", SK: groupId },
+ }),
+ );
+ if (!result.Item) {
+ throw new Error(`No volume group details found for id ${groupId}`);
+ }
+ return $VolumeGroup.parse(result.Item);
+ }
+
+ async getSupplierAllocationsForVolumeGroup(
+ groupId: string,
+ ): Promise {
+ const result = await this.ddbClient.send(
+ new QueryCommand({
+ TableName: this.config.supplierConfigTableName,
+ IndexName: "volumeGroup-index",
+ KeyConditionExpression: "#pk = :pk AND #group = :groupId",
+ FilterExpression: "#status = :status ",
+ ExpressionAttributeNames: {
+ "#pk": "PK",
+ "#group": "volumeGroup",
+ "#status": "status",
+ },
+ ExpressionAttributeValues: {
+ ":pk": "SUPPLIER_ALLOCATION",
+ ":groupId": groupId,
+ ":status": "PROD",
+ },
+ }),
+ );
+ if (!result.Items || result.Items.length === 0) {
+ throw new Error(
+ `No active supplier allocations found for volume group id ${groupId}`,
+ );
+ }
+
+ return $SupplierAllocation.array().parse(result.Items);
+ }
+
+ async getSuppliersDetails(supplierIds: string[]): Promise {
+ const suppliers: Supplier[] = [];
+ for (const supplierId of supplierIds) {
+ const result = await this.ddbClient.send(
+ new GetCommand({
+ TableName: this.config.supplierConfigTableName,
+ Key: { PK: "SUPPLIER", SK: supplierId },
+ }),
+ );
+ if (!result.Item) {
+ throw new Error(`Supplier with id ${supplierId} not found`);
+ }
+ suppliers.push($Supplier.parse(result.Item));
+ }
+ return suppliers;
+ }
+}
diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts
index bb0843f82..62a3f6113 100644
--- a/internal/datastore/src/types.ts
+++ b/internal/datastore/src/types.ts
@@ -53,6 +53,7 @@ export const LetterSchema = LetterSchemaBase.extend({
source: z.string(),
subject: z.string(),
billingRef: z.string(),
+ specificationBillingId: z.string(),
}).describe("Letter");
/**
diff --git a/internal/events/package.json b/internal/events/package.json
index d8b6626c9..cf2e4f0e4 100644
--- a/internal/events/package.json
+++ b/internal/events/package.json
@@ -37,5 +37,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
- "version": "1.0.13"
+ "version": "1.0.14"
}
diff --git a/internal/events/src/domain/letter.ts b/internal/events/src/domain/letter.ts
index 67ed8bbe8..c5aa00597 100644
--- a/internal/events/src/domain/letter.ts
+++ b/internal/events/src/domain/letter.ts
@@ -83,6 +83,13 @@ The identifier will be included as the origin domain in the subject of any corre
examples: ["1y3q9v1zzzz"],
}),
+ specificationBillingId: z.string().meta({
+ title: "Specification Billing ID",
+ description:
+ "The billing ID from the letter specification which was used to produce a letter pack for this request.",
+ examples: ["1y3q9v1zzzz"],
+ }),
+
supplierId: z.string().meta({
title: "Supplier ID",
description: "Supplier ID allocated to the letter during creation.",
diff --git a/internal/events/src/events/__tests__/letter-mapper.test.ts b/internal/events/src/events/__tests__/letter-mapper.test.ts
index c870dc91b..26ea6d00b 100644
--- a/internal/events/src/events/__tests__/letter-mapper.test.ts
+++ b/internal/events/src/events/__tests__/letter-mapper.test.ts
@@ -16,6 +16,7 @@ describe("letter-mapper", () => {
updatedAt: "2025-11-24T15:55:18.000Z",
source: "letter-rendering/source/test",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
} as Letter;
const source = "/data-plane/supplier-api/nhs-supplier-api-dev/main/letters";
const event = mapLetterToCloudEvent(letter, source);
@@ -35,6 +36,7 @@ describe("letter-mapper", () => {
status: "PRINTED",
specificationId: "spec1",
billingRef: "spec1",
+ specificationBillingId: "billing123",
supplierId: "supplier1",
groupId: "group1",
reasonCode: "R02",
diff --git a/internal/events/src/events/__tests__/letter-status-change-events.test.ts b/internal/events/src/events/__tests__/letter-status-change-events.test.ts
index 482155458..6ccd40205 100644
--- a/internal/events/src/events/__tests__/letter-status-change-events.test.ts
+++ b/internal/events/src/events/__tests__/letter-status-change-events.test.ts
@@ -40,6 +40,7 @@ describe("LetterStatus event validations", () => {
billingRef: "1y3q9v1zzzz",
groupId: "client_template",
status,
+ specificationBillingId: "billing123",
}),
}),
);
diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json
index 192ea5e2e..81d4ded6a 100644
--- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json
+++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-invalid-major-version.json
@@ -9,6 +9,7 @@
"source": "/data-plane/letter-rendering/prod/render-pdf",
"subject": "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5"
},
+ "specificationBillingId": "billing123",
"specificationId": "1y3q9v1zzzz",
"status": "ACCEPTED",
"supplierId": "supplier1"
diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json
index 54000422a..0f03dc39f 100644
--- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json
+++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED-with-missing-fields.json
@@ -8,6 +8,7 @@
"event": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"source": "/data-plane/letter-rendering/prod/render-pdf"
},
+ "specificationBillingId": "billing123",
"specificationId": "1y3q9v1zzzz",
"status": "ACCEPTED",
"supplierId": "supplier1"
diff --git a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json
index 7ffac10f3..8182cc1fe 100644
--- a/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json
+++ b/internal/events/src/events/__tests__/testData/letter.ACCEPTED.json
@@ -9,6 +9,7 @@
"source": "/data-plane/letter-rendering/prod/render-pdf",
"subject": "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5"
},
+ "specificationBillingId": "billing123",
"specificationId": "1y3q9v1zzzz",
"status": "ACCEPTED",
"supplierId": "supplier1"
diff --git a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json
index 28c6111f5..fe53b766e 100644
--- a/internal/events/src/events/__tests__/testData/letter.FORWARDED.json
+++ b/internal/events/src/events/__tests__/testData/letter.FORWARDED.json
@@ -11,6 +11,7 @@
},
"reasonCode": "RNIB",
"reasonText": "RNIB",
+ "specificationBillingId": "billing123",
"specificationId": "1y3q9v1zzzz",
"status": "FORWARDED",
"supplierId": "supplier1"
diff --git a/internal/events/src/events/__tests__/testData/letter.RETURNED.json b/internal/events/src/events/__tests__/testData/letter.RETURNED.json
index 07b28154e..f35440e42 100644
--- a/internal/events/src/events/__tests__/testData/letter.RETURNED.json
+++ b/internal/events/src/events/__tests__/testData/letter.RETURNED.json
@@ -11,6 +11,7 @@
},
"reasonCode": "R07",
"reasonText": "No such address",
+ "specificationBillingId": "billing123",
"specificationId": "1y3q9v1zzzz",
"status": "RETURNED",
"supplierId": "supplier1"
diff --git a/internal/events/src/events/letter-mapper.ts b/internal/events/src/events/letter-mapper.ts
index 91f72988a..4b6781e17 100644
--- a/internal/events/src/events/letter-mapper.ts
+++ b/internal/events/src/events/letter-mapper.ts
@@ -25,6 +25,7 @@ export function mapLetterToCloudEvent(
status: letter.status,
specificationId: letter.specificationId,
billingRef: letter.billingRef,
+ specificationBillingId: letter.specificationBillingId,
supplierId: letter.supplierId,
groupId: letter.groupId,
reasonCode: letter.reasonCode,
diff --git a/lambdas/api-handler/jest.config.ts b/lambdas/api-handler/jest.config.ts
index 872794514..174e7f7f9 100644
--- a/lambdas/api-handler/jest.config.ts
+++ b/lambdas/api-handler/jest.config.ts
@@ -9,6 +9,9 @@ export const baseJestConfig = {
},
],
},
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
index fa7f9f81e..d6440eee5 100644
--- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
+++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
@@ -28,6 +28,7 @@ describe("letter-mapper", () => {
ttl: 123,
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
const result: PatchLetterResponse = mapToPatchLetterResponse(letter);
@@ -64,6 +65,7 @@ describe("letter-mapper", () => {
reasonText: "Reason text",
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
const result: PatchLetterResponse = mapToPatchLetterResponse(letter);
@@ -100,6 +102,7 @@ describe("letter-mapper", () => {
ttl: 123,
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
const result: GetLetterResponse = mapToGetLetterResponse(letter);
@@ -136,6 +139,7 @@ describe("letter-mapper", () => {
reasonText: "Reason text",
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
const result: GetLetterResponse = mapToGetLetterResponse(letter);
@@ -174,6 +178,7 @@ describe("letter-mapper", () => {
reasonText: "Reason text",
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
const result: GetLettersResponse = mapToGetLettersResponse([
diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
index 08e103ab0..be69387e1 100644
--- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
+++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
@@ -41,6 +41,7 @@ function makeLetter(id: string, status: Letter["status"]): Letter {
reasonText: "Reason text",
source: "/data-plane/letter-rendering/pdf",
subject: "letter-rendering/source/letter/letter-id",
+ specificationBillingId: "billing123",
};
}
diff --git a/lambdas/letter-updates-transformer/jest.config.ts b/lambdas/letter-updates-transformer/jest.config.ts
index 872794514..174e7f7f9 100644
--- a/lambdas/letter-updates-transformer/jest.config.ts
+++ b/lambdas/letter-updates-transformer/jest.config.ts
@@ -9,6 +9,9 @@ export const baseJestConfig = {
},
],
},
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
diff --git a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
index fd11de133..9a805f44e 100644
--- a/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
+++ b/lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts
@@ -339,6 +339,7 @@ function generateLetter(status: LetterStatus, id?: string): Letter {
supplierStatus: `supplier1#${status}`,
supplierStatusSk: "2025-12-10T11:12:54Z#1",
ttl: 1_234_567_890,
+ specificationBillingId: "billing1",
};
}
diff --git a/lambdas/mi-updates-transformer/jest.config.ts b/lambdas/mi-updates-transformer/jest.config.ts
index f88e72778..93ec73a53 100644
--- a/lambdas/mi-updates-transformer/jest.config.ts
+++ b/lambdas/mi-updates-transformer/jest.config.ts
@@ -26,6 +26,9 @@ export const baseJestConfig: Config = {
coveragePathIgnorePatterns: ["/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
diff --git a/lambdas/supplier-allocator/package.json b/lambdas/supplier-allocator/package.json
index 1b21d001f..2ea6cb679 100644
--- a/lambdas/supplier-allocator/package.json
+++ b/lambdas/supplier-allocator/package.json
@@ -8,6 +8,7 @@
"@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.1",
"@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5",
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1",
"@types/aws-lambda": "^8.10.148",
"aws-lambda": "^1.0.7",
"esbuild": "^0.27.2",
diff --git a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts
index b8b7b736e..7c2767f11 100644
--- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts
+++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts
@@ -2,10 +2,12 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps";
describe("createDependenciesContainer", () => {
const env = {
+ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable",
VARIANT_MAP: {
lv1: {
supplierId: "supplier1",
specId: "spec1",
+ billingId: "billing1",
},
},
};
@@ -25,6 +27,11 @@ describe("createDependenciesContainer", () => {
})),
}));
+ // Repo client
+ jest.mock("@internal/datastore", () => ({
+ SupplierConfigRepository: jest.fn(),
+ }));
+
// Env
jest.mock("../env", () => ({ envVars: env }));
});
@@ -32,12 +39,18 @@ describe("createDependenciesContainer", () => {
test("constructs deps and wires repository config correctly", async () => {
// get current mock instances
const { createLogger } = jest.requireMock("@internal/helpers");
-
+ const { SupplierConfigRepository } = jest.requireMock(
+ "@internal/datastore",
+ );
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { createDependenciesContainer } = require("../deps");
const deps: Deps = createDependenciesContainer();
expect(createLogger).toHaveBeenCalledTimes(1);
-
+ expect(SupplierConfigRepository).toHaveBeenCalledTimes(1);
+ const supplierConfigRepoCtorArgs = SupplierConfigRepository.mock.calls[0];
+ expect(supplierConfigRepoCtorArgs[1]).toEqual({
+ supplierConfigTableName: "SupplierConfigTable",
+ });
expect(deps.env).toEqual(env);
});
});
diff --git a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts
index dd013aebf..d54c2c4f6 100644
--- a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts
+++ b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts
@@ -15,20 +15,24 @@ describe("lambdaEnv", () => {
});
it("should load all environment variables successfully", () => {
+ process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable";
process.env.VARIANT_MAP = `{
"lv1": {
"supplierId": "supplier1",
- "specId": "spec1"
+ "specId": "spec1",
+ "billingId": "billing1"
}
}`;
const { envVars } = require("../env");
expect(envVars).toEqual({
+ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable",
VARIANT_MAP: {
lv1: {
supplierId: "supplier1",
specId: "spec1",
+ billingId: "billing1",
},
},
});
diff --git a/lambdas/supplier-allocator/src/config/deps.ts b/lambdas/supplier-allocator/src/config/deps.ts
index 1a5f6ce5d..4d51f9a07 100644
--- a/lambdas/supplier-allocator/src/config/deps.ts
+++ b/lambdas/supplier-allocator/src/config/deps.ts
@@ -1,18 +1,40 @@
+import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
+import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { SQSClient } from "@aws-sdk/client-sqs";
import { Logger } from "pino";
import { createLogger } from "@internal/helpers";
+import { SupplierConfigRepository } from "@internal/datastore";
import { EnvVars, envVars } from "./env";
export type Deps = {
+ supplierConfigRepo: SupplierConfigRepository;
logger: Logger;
env: EnvVars;
sqsClient: SQSClient;
};
+function createDocumentClient(): DynamoDBDocumentClient {
+ const ddbClient = new DynamoDBClient({});
+ return DynamoDBDocumentClient.from(ddbClient);
+}
+
+function createSupplierConfigRepository(
+ log: Logger,
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ envVars: EnvVars,
+): SupplierConfigRepository {
+ const config = {
+ supplierConfigTableName: envVars.SUPPLIER_CONFIG_TABLE_NAME,
+ };
+
+ return new SupplierConfigRepository(createDocumentClient(), config);
+}
+
export function createDependenciesContainer(): Deps {
const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL });
return {
+ supplierConfigRepo: createSupplierConfigRepository(log, envVars),
logger: log,
env: envVars,
sqsClient: new SQSClient({}),
diff --git a/lambdas/supplier-allocator/src/config/env.ts b/lambdas/supplier-allocator/src/config/env.ts
index 0adc39203..a3ef6bf0d 100644
--- a/lambdas/supplier-allocator/src/config/env.ts
+++ b/lambdas/supplier-allocator/src/config/env.ts
@@ -5,11 +5,13 @@ const LetterVariantSchema = z.record(
z.object({
supplierId: z.string(),
specId: z.string(),
+ billingId: z.string(),
}),
);
export type LetterVariant = z.infer;
const EnvVarsSchema = z.object({
+ SUPPLIER_CONFIG_TABLE_NAME: z.string(),
PINO_LOG_LEVEL: z.coerce.string().optional(),
VARIANT_MAP: z.string().transform((str, _) => {
const parsed = JSON.parse(str);
diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
index 23fc5981c..646a2d2fe 100644
--- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
+++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts
@@ -1,438 +1,373 @@
-import { SQSEvent, SQSRecord } from "aws-lambda";
-import pino from "pino";
-import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
+import { SQSBatchResponse, SQSEvent, SQSRecord } from "aws-lambda";
+import { SendMessageCommand } from "@aws-sdk/client-sqs";
import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
-import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
-import {
- $LetterEvent,
- LetterEvent,
-} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
import createSupplierAllocatorHandler from "../allocate-handler";
+import * as supplierConfig from "../../services/supplier-config";
import { Deps } from "../../config/deps";
-import { EnvVars } from "../../config/env";
-function createSQSEvent(records: SQSRecord[]): SQSEvent {
- return {
- Records: records,
+jest.mock("@aws-sdk/client-sqs");
+jest.mock("../../services/supplier-config");
+
+function makeDeps(overrides: Partial = {}): Deps {
+ const logger = {
+ info: jest.fn(),
+ error: jest.fn(),
};
-}
-function createSqsRecord(msgId: string, body: string): SQSRecord {
- return {
- messageId: msgId,
- receiptHandle: "",
- body,
- attributes: {
- ApproximateReceiveCount: "",
- SentTimestamp: "",
- SenderId: "",
- ApproximateFirstReceiveTimestamp: "",
- },
- messageAttributes: {},
- md5OfBody: "",
- eventSource: "",
- eventSourceARN: "",
- awsRegion: "",
+ const sqsClient = {
+ send: jest.fn().mockResolvedValue({}),
};
-}
-function createPreparedV1Event(
- overrides: Partial = {},
-): LetterRequestPreparedEvent {
- const now = new Date().toISOString();
+ const supplierConfigRepo = {
+ getLetterVariant: jest.fn(),
+ getVolumeGroup: jest.fn(),
+ getSupplierAllocationsForVolumeGroup: jest.fn(),
+ };
- return {
- specversion: "1.0",
- id: overrides.id ?? "7b9a03ca-342a-4150-b56b-989109c45613",
- source: "/data-plane/letter-rendering/test",
- subject: "client/client1/letter-request/letterRequest1",
- type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1",
- time: now,
- dataschema:
- "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json",
- dataschemaversion: "1.0.0",
- data: {
- domainId: overrides.domainId ?? "letter1",
- letterVariantId: "lv1",
- requestId: "request1",
- requestItemId: "requestItem1",
- requestItemPlanId: "requestItemPlan1",
- clientId: "client1",
- campaignId: "campaign1",
- templateId: "template1",
- url: overrides.url ?? "s3://letterDataBucket/letter1.pdf",
- sha256Hash:
- "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6",
- createdAt: now,
- pageCount: 1,
- status: "PREPARED",
+ const env = {
+ VARIANT_MAP: {
+ "variant-1": {
+ supplierId: "supplier-1",
+ specId: "spec-1",
+ billingId: "billing-1",
+ },
+ "variant-2": {
+ supplierId: "supplier-2",
+ specId: "spec-2",
+ billingId: "billing-2",
+ },
},
- traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01",
- recordedtime: now,
- severitynumber: 2,
- severitytext: "INFO",
- plane: "data",
};
+
+ return {
+ logger: logger as any,
+ sqsClient: sqsClient as any,
+ supplierConfigRepo: supplierConfigRepo as any,
+ env: env as any,
+ ...overrides,
+ } as any;
}
-function createPreparedV2Event(
- overrides: Partial = {},
+function makeLetterEventV2(
+ variantId = "variant-1",
): LetterRequestPreparedEventV2 {
return {
- ...createPreparedV1Event(overrides),
type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v2",
- dataschema:
- "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.2.0.1.schema.json",
- dataschemaversion: "2.0.1",
- };
+ data: {
+ letterVariantId: variantId,
+ eventId: "event-123",
+ timestamp: new Date().toISOString(),
+ },
+ } as any;
}
-function createSupplierStatusChangeEvent(
- overrides: Partial = {},
-): LetterEvent {
- const now = new Date().toISOString();
-
- return $LetterEvent.parse({
- data: {
- domainId: overrides.domainId ?? "f47ac10b-58cc-4372-a567-0e02b2c3d479",
- groupId: "client_template",
- origin: {
- domain: "letter-rendering",
- event: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
- source: "/data-plane/letter-rendering/prod/render-pdf",
- subject:
- "client/00f3b388-bbe9-41c9-9e76-052d37ee8988/letter-request/0o5Fs0EELR0fUjHjbCnEtdUwQe4_0o5Fs0EELR0fUjHjbCnEtdUwQe5",
- },
- reasonCode: "R07",
- reasonText: "No such address",
- specificationId: "1y3q9v1zzzz",
- billingRef: "1y3q9v1zzzz",
- status: "RETURNED",
- supplierId: "supplier1",
+function makeSQSRecord(body: any): SQSRecord {
+ return {
+ messageId: "message-123",
+ receiptHandle: "receipt-handle-123",
+ body: JSON.stringify(body),
+ attributes: {
+ ApproximateReceiveCount: "1",
+ SentTimestamp: Date.now().toString(),
+ SenderId: "sender-123",
+ ApproximateFirstReceiveTimestamp: Date.now().toString(),
},
- datacontenttype: "application/json",
- dataschema:
- "https://notify.nhs.uk/cloudevents/schemas/supplier-api/letter.RETURNED.1.0.0.schema.json",
- dataschemaversion: "1.0.0",
- id: overrides.id ?? "23f1f09c-a555-4d9b-8405-0b33490bc920",
- plane: "data",
- recordedtime: now,
- severitynumber: 2,
- severitytext: "INFO",
- source: "/data-plane/supplier-api/prod/update-status",
- specversion: "1.0",
- subject:
- "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479",
- time: now,
- traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
- type: "uk.nhs.notify.supplier-api.letter.RETURNED.v1",
- });
+ messageAttributes: {},
+ md5OfBody: "md5",
+ eventSource: "aws:sqs",
+ eventSourceARN: "arn:aws:sqs:region:account:queue",
+ awsRegion: "us-east-1",
+ } as any;
}
-describe("createSupplierAllocatorHandler", () => {
- let mockSqsClient: jest.Mocked;
- let mockedDeps: jest.Mocked;
+function setupDefaultMocks() {
+ (supplierConfig.getVariantDetails as jest.Mock).mockResolvedValue({
+ id: "v1",
+ volumeGroupId: "g1",
+ });
+ (supplierConfig.getVolumeGroupDetails as jest.Mock).mockResolvedValue({
+ id: "g1",
+ status: "PROD",
+ });
+ (
+ supplierConfig.getSupplierAllocationsForVolumeGroup as jest.Mock
+ ).mockResolvedValue([{ supplier: "s1" }]);
+ (supplierConfig.getSupplierDetails as jest.Mock).mockResolvedValue({
+ supplierId: "supplier-1",
+ specId: "spec-1",
+ });
+}
+describe("allocate-handler", () => {
beforeEach(() => {
- mockSqsClient = {
- send: jest.fn(),
- } as unknown as jest.Mocked;
-
- mockedDeps = {
- logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger,
- env: {
- VARIANT_MAP: {
- lv1: {
- supplierId: "supplier1",
- specId: "spec1",
- },
- },
- } as EnvVars,
- sqsClient: mockSqsClient,
- } as jest.Mocked;
-
jest.clearAllMocks();
+ process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.queue";
});
- test("parses SNS notification and sends message to SQS queue for v2 event", async () => {
- const preparedEvent = createPreparedV2Event();
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
-
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
-
- expect(result).toBeDefined();
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(0);
-
- expect(mockSqsClient.send).toHaveBeenCalledTimes(1);
- const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
- expect(sendCall).toBeInstanceOf(SendMessageCommand);
-
- const messageBody = JSON.parse(sendCall.input.MessageBody);
- expect(messageBody.letterEvent).toEqual(preparedEvent);
- expect(messageBody.supplierSpec).toEqual({
- supplierId: "supplier1",
- specId: "spec1",
- });
+ afterEach(() => {
+ delete process.env.UPSERT_LETTERS_QUEUE_URL;
});
- test("parses SNS notification and sends message to SQS queue for v1 event", async () => {
- const preparedEvent = createPreparedV1Event();
-
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
-
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
-
- expect(result).toBeDefined();
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(0);
-
- expect(mockSqsClient.send).toHaveBeenCalledTimes(1);
- const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
- const messageBody = JSON.parse(sendCall.input.MessageBody);
- expect(messageBody.supplierSpec).toEqual({
- supplierId: "supplier1",
- specId: "spec1",
+ describe("createSupplierAllocatorHandler", () => {
+ it("creates a handler function", () => {
+ const deps = makeDeps();
+ const handler = createSupplierAllocatorHandler(deps);
+ expect(handler).toBeInstanceOf(Function);
});
});
- test("returns batch failure for Update event", async () => {
- const preparedEvent = createSupplierStatusChangeEvent();
-
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("invalid-event", JSON.stringify(preparedEvent)),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
-
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
-
- expect(result).toBeDefined();
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("invalid-event");
- expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1);
- });
-
- test("unwraps EventBridge envelope and extracts event details", async () => {
- const preparedEvent = createPreparedV2Event({ domainId: "letter-test" });
-
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ describe("handler execution", () => {
+ it("processes single record successfully", async () => {
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2();
+
+ setupDefaultMocks();
+
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
+
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
+
+ expect(result.batchItemFailures).toHaveLength(0);
+ expect(deps.sqsClient.send).toHaveBeenCalledWith(
+ expect.any(SendMessageCommand),
+ );
+ expect(deps.logger.info).toHaveBeenCalled();
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- await handler(evt, {} as any, {} as any);
+ it("processes multiple records", async () => {
+ const deps = makeDeps();
+ const letterEvent1 = makeLetterEventV2("variant-1");
+ const letterEvent2 = makeLetterEventV2("variant-2");
- const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
- const messageBody = JSON.parse(sendCall.input.MessageBody);
- expect(messageBody.letterEvent.data.domainId).toBe("letter-test");
- });
+ setupDefaultMocks();
- test("resolves correct supplier spec from variant map", async () => {
- const preparedEvent = createPreparedV2Event();
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent1), makeSQSRecord(letterEvent2)],
+ };
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ expect(result.batchItemFailures).toHaveLength(0);
+ expect(deps.sqsClient.send).toHaveBeenCalledTimes(2);
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- await handler(evt, {} as any, {} as any);
+ it("records failures for invalid JSON", async () => {
+ const deps = makeDeps();
+ const handler = createSupplierAllocatorHandler(deps);
- const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
- const messageBody = JSON.parse(sendCall.input.MessageBody);
- expect(messageBody.supplierSpec.supplierId).toBe("supplier1");
- expect(messageBody.supplierSpec.specId).toBe("spec1");
- });
+ const badRecord = makeSQSRecord("");
+ badRecord.body = "invalid json {";
- test("processes multiple messages in batch", async () => {
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord(
- "msg1",
- JSON.stringify(createPreparedV2Event({ domainId: "letter1" })),
- ),
- createSqsRecord(
- "msg2",
- JSON.stringify(createPreparedV2Event({ domainId: "letter2" })),
- ),
- ]);
+ const event: SQSEvent = {
+ Records: [badRecord],
+ };
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
-
- expect(result).toBeDefined();
- if (!result) throw new Error("expected BatchResponse, got void");
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(result.batchItemFailures[0].itemIdentifier).toBe("message-123");
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- expect(result.batchItemFailures).toHaveLength(0);
- expect(mockSqsClient.send).toHaveBeenCalledTimes(2);
- });
+ it("records failures for invalid event type", async () => {
+ const deps = makeDeps();
+ const handler = createSupplierAllocatorHandler(deps);
- test("returns batch failure for invalid JSON", async () => {
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("bad-json", "this-is-not-json"),
- ]);
+ const invalidEvent = { type: "invalid.type" };
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(invalidEvent)],
+ };
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- expect(result).toBeDefined();
- if (!result) throw new Error("expected BatchResponse, got void");
+ it("records failures for missing envelope type", async () => {
+ const deps = makeDeps();
+ const handler = createSupplierAllocatorHandler(deps);
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("bad-json");
- expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1);
- });
+ const invalidEvent = { data: { letterVariantId: "v1" } };
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(invalidEvent)],
+ };
- test("returns batch failure when event type is missing", async () => {
- const event = { no: "type" };
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("no-type", JSON.stringify(event)),
- ]);
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ it("records failures for missing variant mapping", async () => {
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2("unknown-variant");
+
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
+
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
+
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.logger.error).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Error processing allocation of record",
+ }),
+ );
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
- if (!result) throw new Error("expected BatchResponse, got void");
+ it("records failures when supplier config resolution fails", async () => {
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2();
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("no-type");
- });
+ (supplierConfig.getVariantDetails as jest.Mock).mockRejectedValue(
+ new Error("Database error"),
+ );
- test("returns batch failure when UPSERT_LETTERS_QUEUE_URL is not set", async () => {
- const preparedEvent = createPreparedV2Event();
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- delete process.env.UPSERT_LETTERS_QUEUE_URL;
+ expect(result.batchItemFailures).toHaveLength(0);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
- expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0].err).toEqual(
- expect.objectContaining({
- message: "UPSERT_LETTERS_QUEUE_URL not configured",
- }),
- );
- });
+ it("records failures when UPSERT_LETTERS_QUEUE_URL not configured", async () => {
+ delete process.env.UPSERT_LETTERS_QUEUE_URL;
- test("returns batch failure when variant mapping is missing", async () => {
- const preparedEvent = createPreparedV2Event();
- preparedEvent.data.letterVariantId = "missing-variant";
-
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
-
- // Override variant map to be empty for this test
- mockedDeps.env.VARIANT_MAP = {} as any;
-
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
- expect(
- (mockedDeps.logger.error as jest.Mock).mock.calls.length,
- ).toBeGreaterThan(0);
- expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual(
- expect.objectContaining({
- description: "No supplier mapping found for variant",
- }),
- );
- });
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2();
- test("handles SQS send errors and returns batch failure", async () => {
- const preparedEvent = createPreparedV2Event();
+ setupDefaultMocks();
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- const sqsError = new Error("SQS send failed");
- (mockSqsClient.send as jest.Mock).mockRejectedValueOnce(sqsError);
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
- if (!result) throw new Error("expected BatchResponse, got void");
+ it("records failures when SQS send fails", async () => {
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2();
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
- expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1);
- });
+ setupDefaultMocks();
- test("processes mixed batch with successes and failures", async () => {
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord(
- "ok-msg",
- JSON.stringify(createPreparedV2Event({ domainId: "letter1" })),
- ),
- createSqsRecord("fail-msg", "invalid-json"),
- createSqsRecord(
- "ok-msg-2",
- JSON.stringify(createPreparedV2Event({ domainId: "letter2" })),
- ),
- ]);
-
- process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
-
- const handler = createSupplierAllocatorHandler(mockedDeps);
- const result = await handler(evt, {} as any, {} as any);
- if (!result) throw new Error("expected BatchResponse, got void");
-
- expect(result.batchItemFailures).toHaveLength(1);
- expect(result.batchItemFailures[0].itemIdentifier).toBe("fail-msg");
-
- expect(mockSqsClient.send).toHaveBeenCalledTimes(2);
- });
+ deps.sqsClient.send = jest.fn().mockRejectedValue(new Error("SQS error"));
- test("sends correct queue URL in SQS message command", async () => {
- const preparedEvent = createPreparedV2Event();
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
- const evt: SQSEvent = createSQSEvent([
- createSqsRecord("msg1", JSON.stringify(preparedEvent)),
- ]);
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
- const queueUrl = "https://sqs.eu-west-2.amazonaws.com/123456789/test-queue";
- process.env.UPSERT_LETTERS_QUEUE_URL = queueUrl;
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
- const handler = createSupplierAllocatorHandler(mockedDeps);
- await handler(evt, {} as any, {} as any);
+ it("handles mixed success and failure records", async () => {
+ const deps = makeDeps();
+ const letterEventGood = makeLetterEventV2("variant-1");
+ const letterEventBad = { type: "invalid.type" };
+
+ setupDefaultMocks();
+
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [
+ makeSQSRecord(letterEventGood),
+ makeSQSRecord(letterEventBad),
+ ],
+ };
+
+ const result = (await handler(
+ event,
+ {} as any,
+ () => {},
+ )) as SQSBatchResponse;
+
+ expect(result.batchItemFailures).toHaveLength(1);
+ expect(deps.sqsClient.send).toHaveBeenCalledTimes(1);
+ });
- const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
- expect(sendCall.input.QueueUrl).toBe(queueUrl);
+ it("logs extraction and resolution for successful records", async () => {
+ const deps = makeDeps();
+ const letterEvent = makeLetterEventV2();
+
+ setupDefaultMocks();
+
+ const handler = createSupplierAllocatorHandler(deps);
+ const event: SQSEvent = {
+ Records: [makeSQSRecord(letterEvent)],
+ };
+
+ await handler(event, {} as any, () => {});
+
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Extracted letter event",
+ }),
+ );
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Resolved supplier spec",
+ }),
+ );
+ expect(deps.logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ description: "Sending message to upsert letter queue",
+ }),
+ );
+ });
});
});
diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts
index 0402e2846..89f370804 100644
--- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts
+++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts
@@ -1,12 +1,23 @@
import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda";
import { SendMessageCommand } from "@aws-sdk/client-sqs";
import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1";
-
+import {
+ LetterVariant,
+ Supplier,
+ SupplierAllocation,
+ VolumeGroup,
+} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
import z from "zod";
+import {
+ getSupplierAllocationsForVolumeGroup,
+ getSupplierDetails,
+ getVariantDetails,
+ getVolumeGroupDetails,
+} from "../services/supplier-config";
import { Deps } from "../config/deps";
-type SupplierSpec = { supplierId: string; specId: string };
+type SupplierSpec = { supplierId: string; specId: string; billingId: string };
type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent;
// small envelope that must exist in all inputs
@@ -46,6 +57,48 @@ function validateType(event: unknown) {
}
}
+async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) {
+ try {
+ const variantDetails: LetterVariant = await getVariantDetails(
+ letterEvent.data.letterVariantId,
+ deps,
+ );
+
+ const volumeGroupDetails: VolumeGroup = await getVolumeGroupDetails(
+ variantDetails.volumeGroupId,
+ deps,
+ );
+
+ const supplierAllocations: SupplierAllocation[] =
+ await getSupplierAllocationsForVolumeGroup(
+ variantDetails.volumeGroupId,
+ deps,
+ variantDetails.supplierId,
+ );
+
+ const supplierDetails: Supplier[] = await getSupplierDetails(
+ supplierAllocations,
+ deps,
+ );
+ deps.logger.info({
+ description: "Fetched supplier details for supplier allocations",
+ variantId: letterEvent.data.letterVariantId,
+ volumeGroupId: volumeGroupDetails.id,
+ supplierAllocationIds: supplierAllocations.map((a) => a.id),
+ supplierDetails,
+ });
+
+ return supplierDetails;
+ } catch (error) {
+ deps.logger.error({
+ description: "Error fetching supplier from config",
+ err: error,
+ variantId: letterEvent.data.letterVariantId,
+ });
+ return [];
+ }
+}
+
function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec {
return resolveSupplierForVariant(letterEvent.data.letterVariantId, deps);
}
@@ -66,6 +119,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler {
validateType(letterEvent);
const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps);
+ await getSupplierFromConfig(letterEvent as PreparedEvents, deps);
deps.logger.info({
description: "Resolved supplier spec",
diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts
new file mode 100644
index 000000000..b6fa8e1e8
--- /dev/null
+++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts
@@ -0,0 +1,287 @@
+import {
+ getSupplierAllocationsForVolumeGroup,
+ getSupplierDetails,
+ getVariantDetails,
+ getVolumeGroupDetails,
+} from "../supplier-config";
+import { Deps } from "../../config/deps";
+
+function makeDeps(overrides: Partial = {}): Deps {
+ const logger = {
+ info: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ } as unknown as Deps["logger"];
+
+ const supplierConfigRepo = {
+ getLetterVariant: jest.fn(),
+ getVolumeGroup: jest.fn(),
+ getSupplierAllocationsForVolumeGroup: jest.fn(),
+ } as unknown as Deps["supplierConfigRepo"];
+
+ const base: Partial = {
+ logger: logger as any,
+ supplierConfigRepo: supplierConfigRepo as any,
+ };
+
+ return { ...(base as Deps), ...overrides } as Deps;
+}
+
+describe("supplier-config service", () => {
+ afterEach(() => jest.resetAllMocks());
+
+ describe("getVariantDetails", () => {
+ it("returns variant details", async () => {
+ const variant = { id: "v1", volumeGroupId: "g1" } as any;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getLetterVariant = jest
+ .fn()
+ .mockResolvedValue(variant);
+
+ const result = await getVariantDetails("v1", deps);
+
+ expect(result).toBe(variant);
+ });
+
+ it("returns undefined when not found", async () => {
+ const variant = undefined;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getLetterVariant = jest
+ .fn()
+ .mockResolvedValue(variant);
+
+ const result = await getVariantDetails("missing", deps);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe("getVolumeGroupDetails", () => {
+ it("returns group details when active", async () => {
+ const group = {
+ id: "g1",
+ status: "PROD",
+ startDate: "2020-01-01",
+ } as any;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(group);
+
+ const result = await getVolumeGroupDetails("g1", deps);
+
+ expect(result).toBe(group);
+ });
+ it("logs an error and returns group details when not found", async () => {
+ const group = undefined;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(group);
+
+ const result = await getVolumeGroupDetails("missing", deps);
+
+ expect(result).toBeUndefined();
+ });
+
+ it("throws when group is not active based on status", async () => {
+ const group = {
+ id: "g2",
+ status: "DRAFT",
+ startDate: "2020-01-01",
+ } as any;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(group);
+
+ await expect(getVolumeGroupDetails("g2", deps)).rejects.toThrow(
+ /not active/,
+ );
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
+
+ it("throws when group is not active based on start date", async () => {
+ const future = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();
+ const group = { id: "g3", status: "PROD", startDate: future } as any;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(group);
+
+ await expect(getVolumeGroupDetails("g3", deps)).rejects.toThrow(
+ /not active/,
+ );
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
+
+ it("throws when group is not active based on end date", async () => {
+ const past = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString();
+ const group = {
+ id: "g3",
+ status: "PROD",
+ startDate: "2020-01-01",
+ endDate: past,
+ } as any;
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(group);
+
+ await expect(getVolumeGroupDetails("g3", deps)).rejects.toThrow(
+ /not active/,
+ );
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
+ });
+
+ describe("getSupplierAllocationsForVolumeGroup", () => {
+ const allocations = [
+ { supplier: "s1", variantId: "v1" },
+ { supplier: "s2", variantId: "v2" },
+ ] as any[];
+
+ it("returns all allocations when no supplierId provided", async () => {
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(allocations);
+
+ const result = await getSupplierAllocationsForVolumeGroup("g1", deps);
+
+ expect(result).toEqual(allocations);
+ });
+
+ it("filters by supplierId when provided", async () => {
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(allocations);
+
+ const result = await getSupplierAllocationsForVolumeGroup(
+ "g1",
+ deps,
+ "s2",
+ );
+
+ expect(result).toEqual([allocations[1]]);
+ });
+
+ it("throws when supplierId provided but no matching allocation", async () => {
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup = jest
+ .fn()
+ .mockResolvedValue(allocations);
+
+ await expect(
+ getSupplierAllocationsForVolumeGroup("g1", deps, "missing"),
+ ).rejects.toThrow(/No supplier allocations found/);
+ expect(deps.logger.error).toHaveBeenCalled();
+ });
+ });
+
+ describe("getSupplierDetails", () => {
+ it("returns supplier details when found", async () => {
+ const allocations = [
+ { supplier: "s1", variantId: "v1" },
+ { supplier: "s2", variantId: "v2" },
+ ] as any[];
+ const suppliers = [
+ { id: "s1", name: "Supplier 1" },
+ { id: "s2", name: "Supplier 2" },
+ ] as any[];
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSuppliersDetails = jest
+ .fn()
+ .mockResolvedValue(suppliers);
+
+ const result = await getSupplierDetails(allocations, deps);
+
+ expect(result).toEqual(suppliers);
+ expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([
+ "s1",
+ "s2",
+ ]);
+ });
+
+ it("throws when no supplier details found", async () => {
+ const allocations = [{ supplier: "s1", variantId: "v1" }] as any[];
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSuppliersDetails = jest
+ .fn()
+ .mockResolvedValue([]);
+
+ await expect(getSupplierDetails(allocations, deps)).rejects.toThrow(
+ /No supplier details found/,
+ );
+ });
+
+ it("extracts supplier ids from allocations and requests details", async () => {
+ const allocations = [
+ { supplier: "s1", variantId: "v1" },
+ { supplier: "s3", variantId: "v2" },
+ { supplier: "s5", variantId: "v3" },
+ ] as any[];
+ const suppliers = [
+ { id: "s1", name: "Supplier 1" },
+ { id: "s3", name: "Supplier 3" },
+ { id: "s5", name: "Supplier 5" },
+ ] as any[];
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSuppliersDetails = jest
+ .fn()
+ .mockResolvedValue(suppliers);
+
+ await getSupplierDetails(allocations, deps);
+
+ expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([
+ "s1",
+ "s3",
+ "s5",
+ ]);
+ });
+ });
+ it("logs a warning when supplier allocations count differs from supplier details count", async () => {
+ const allocations = [
+ { supplier: "s1", variantId: "v1" },
+ { supplier: "s2", variantId: "v2" },
+ { supplier: "s3", variantId: "v3" },
+ ] as any[];
+ const suppliers = [
+ { id: "s1", name: "Supplier 1" },
+ { id: "s2", name: "Supplier 2" },
+ ] as any[];
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSuppliersDetails = jest
+ .fn()
+ .mockResolvedValue(suppliers);
+
+ await getSupplierDetails(allocations, deps);
+
+ expect(deps.logger.warn).toHaveBeenCalledWith({
+ description: "Mismatch between supplier allocations and supplier details",
+ allocationsCount: 3,
+ detailsCount: 2,
+ missingSuppliers: ["s3"],
+ });
+ });
+
+ it("does not log a warning when counts match", async () => {
+ const allocations = [
+ { supplier: "s1", variantId: "v1" },
+ { supplier: "s2", variantId: "v2" },
+ ] as any[];
+ const suppliers = [
+ { id: "s1", name: "Supplier 1" },
+ { id: "s2", name: "Supplier 2" },
+ ] as any[];
+ const deps = makeDeps();
+ deps.supplierConfigRepo.getSuppliersDetails = jest
+ .fn()
+ .mockResolvedValue(suppliers);
+
+ await getSupplierDetails(allocations, deps);
+
+ expect(deps.logger.warn).not.toHaveBeenCalled();
+ });
+});
diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts
new file mode 100644
index 000000000..f86195209
--- /dev/null
+++ b/lambdas/supplier-allocator/src/services/supplier-config.ts
@@ -0,0 +1,103 @@
+import {
+ LetterVariant,
+ Supplier,
+ SupplierAllocation,
+ VolumeGroup,
+} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
+import { Deps } from "../config/deps";
+
+export async function getVariantDetails(
+ variantId: string,
+ deps: Deps,
+): Promise {
+ const variantDetails: LetterVariant =
+ await deps.supplierConfigRepo.getLetterVariant(variantId);
+ return variantDetails;
+}
+
+export async function getVolumeGroupDetails(
+ groupId: string,
+ deps: Deps,
+): Promise {
+ const groupDetails = await deps.supplierConfigRepo.getVolumeGroup(groupId);
+
+ if (
+ groupDetails &&
+ (groupDetails.status !== "PROD" ||
+ new Date(groupDetails.startDate) > new Date() ||
+ (groupDetails.endDate && new Date(groupDetails.endDate) < new Date()))
+ ) {
+ deps.logger.error({
+ description: "Volume group is not active based on status and dates",
+ groupId,
+ status: groupDetails.status,
+ startDate: groupDetails.startDate,
+ endDate: groupDetails.endDate,
+ });
+ throw new Error(`Volume group with id ${groupId} is not active`);
+ }
+ return groupDetails;
+}
+
+export async function getSupplierAllocationsForVolumeGroup(
+ groupId: string,
+ deps: Deps,
+ supplierId?: string,
+): Promise {
+ const allocations =
+ await deps.supplierConfigRepo.getSupplierAllocationsForVolumeGroup(groupId);
+
+ if (supplierId) {
+ const filteredAllocations = allocations.filter(
+ (alloc) => alloc.supplier === supplierId,
+ );
+ if (filteredAllocations.length === 0) {
+ deps.logger.error({
+ description:
+ "No supplier allocations found for variantsupplier id in volume group",
+ groupId,
+ supplierId,
+ });
+ throw new Error(
+ `No supplier allocations found for variant supplier id ${supplierId} in volume group ${groupId}`,
+ );
+ }
+ return filteredAllocations;
+ }
+
+ return allocations;
+}
+
+export async function getSupplierDetails(
+ supplierAllocations: SupplierAllocation[],
+ deps: Deps,
+): Promise {
+ const supplierIds = supplierAllocations.map((alloc) => alloc.supplier);
+
+ const supplierDetails: Supplier[] =
+ await deps.supplierConfigRepo.getSuppliersDetails(supplierIds);
+
+ if (Object.keys(supplierDetails).length === 0) {
+ deps.logger.error({
+ description: "No supplier details found for supplier allocations",
+ supplierIds,
+ });
+ throw new Error(
+ `No supplier details found for supplier ids ${supplierIds.join(", ")}`,
+ );
+ }
+ // Log a warning if some supplier details are missing compared to allocations
+ if (supplierAllocations.length !== supplierDetails.length) {
+ const foundSupplierIds = new Set(supplierDetails.map((s) => s.id));
+ const missingSupplierIds = supplierIds.filter(
+ (id) => !foundSupplierIds.has(id),
+ );
+ deps.logger.warn({
+ description: "Mismatch between supplier allocations and supplier details",
+ allocationsCount: supplierAllocations.length,
+ detailsCount: supplierDetails.length,
+ missingSuppliers: missingSupplierIds,
+ });
+ }
+ return supplierDetails;
+}
diff --git a/lambdas/update-letter-queue/jest.config.ts b/lambdas/update-letter-queue/jest.config.ts
index e173d5e31..c6f651597 100644
--- a/lambdas/update-letter-queue/jest.config.ts
+++ b/lambdas/update-letter-queue/jest.config.ts
@@ -9,6 +9,9 @@ export const baseJestConfig = {
},
],
},
+ transformIgnorePatterns: [
+ "node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
+ ],
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
diff --git a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts
index 03f9ff720..f7475a8ce 100644
--- a/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts
+++ b/lambdas/update-letter-queue/src/__tests__/update-letter-queue.test.ts
@@ -46,6 +46,7 @@ function generateLetter(status: LetterStatus, id?: string): Letter {
source: "test-source",
subject: "test-subject",
billingRef: "billing-ref-1",
+ specificationBillingId: "billing1",
};
}
diff --git a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
index 6292de0f7..e32d47d96 100644
--- a/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
+++ b/lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts
@@ -97,6 +97,7 @@ function createSupplierStatusChangeEventWithoutSupplier(
billingRef: "1y3q9v1zzzz",
status: "RETURNED",
supplierId: "",
+ specificationBillingId: "billing1",
},
datacontenttype: "application/json",
dataschema:
@@ -151,6 +152,7 @@ function createSupplierStatusChangeEvent(
billingRef: "1y3q9v1zzzz",
status: "RETURNED",
supplierId: "supplier1",
+ specificationBillingId: "billing1",
},
datacontenttype: "application/json",
dataschema:
@@ -211,11 +213,19 @@ describe("createUpsertLetterHandler", () => {
test("processes all records successfully and returns no batch failures", async () => {
const v2message = {
letterEvent: createPreparedV2Event(),
- supplierSpec: { supplierId: "supplier1", specId: "spec1" },
+ supplierSpec: {
+ supplierId: "supplier1",
+ specId: "spec1",
+ billingId: "billing1",
+ },
};
const v1message = {
letterEvent: createPreparedV1Event(),
- supplierSpec: { supplierId: "supplier1", specId: "spec1" },
+ supplierSpec: {
+ supplierId: "supplier2",
+ specId: "spec2",
+ billingId: "billing2",
+ },
};
const evt: SQSEvent = createSQSEvent([
@@ -249,17 +259,19 @@ describe("createUpsertLetterHandler", () => {
expect(insertedV2Letter.status).toBe("PENDING");
expect(insertedV2Letter.groupId).toBe("client1campaign1template1");
expect(insertedV2Letter.source).toBe("/data-plane/letter-rendering/test");
+ expect(insertedV2Letter.specificationBillingId).toBe("billing1");
const insertedV1Letter = (mockedDeps.letterRepo.putLetter as jest.Mock).mock
.calls[1][0];
expect(insertedV1Letter.id).toBe("letter1");
- expect(insertedV1Letter.supplierId).toBe("supplier1");
- expect(insertedV1Letter.specificationId).toBe("spec1");
- expect(insertedV1Letter.billingRef).toBe("spec1");
+ expect(insertedV1Letter.supplierId).toBe("supplier2");
+ expect(insertedV1Letter.specificationId).toBe("spec2");
+ expect(insertedV1Letter.billingRef).toBe("spec2");
expect(insertedV1Letter.url).toBe("s3://letterDataBucket/letter1.pdf");
expect(insertedV1Letter.status).toBe("PENDING");
expect(insertedV1Letter.groupId).toBe("client1campaign1template1");
expect(insertedV1Letter.source).toBe("/data-plane/letter-rendering/test");
+ expect(insertedV1Letter.specificationBillingId).toBe("billing2");
const updatedLetter = (
mockedDeps.letterRepo.updateLetterStatus as jest.Mock
@@ -275,7 +287,12 @@ describe("createUpsertLetterHandler", () => {
});
expect(mockMetrics.putMetric).toHaveBeenCalledWith(
"MessagesProcessed",
- 3,
+ 2,
+ "Count",
+ );
+ expect(mockMetrics.putMetric).toHaveBeenCalledWith(
+ "MessagesProcessed",
+ 1,
"Count",
);
});
@@ -472,14 +489,22 @@ describe("createUpsertLetterHandler", () => {
id: "7b9a03ca-342a-4150-b56b-989109c45615",
domainId: "ok",
}),
- supplierSpec: { supplierId: "supplier1", specId: "spec1" },
+ supplierSpec: {
+ supplierId: "supplier1",
+ specId: "spec1",
+ billingId: "billing1",
+ },
};
const message2 = {
letterEvent: createPreparedV2Event({
id: "7b9a03ca-342a-4150-b56b-989109c45616",
domainId: "fail",
}),
- supplierSpec: { supplierId: "supplier1", specId: "spec1" },
+ supplierSpec: {
+ supplierId: "supplier1",
+ specId: "spec1",
+ billingId: "billing1",
+ },
};
const evt: SQSEvent = createSQSEvent([
createSqsRecord("ok-msg", JSON.stringify(message1)),
diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts
index ada416ec2..e332096fb 100644
--- a/lambdas/upsert-letter/src/handler/upsert-handler.ts
+++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts
@@ -16,12 +16,13 @@ import z from "zod";
import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics";
import { Deps } from "../config/deps";
-type SupplierSpec = { supplierId: string; specId: string };
+type SupplierSpec = { supplierId: string; specId: string; billingId: string };
type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent;
const SupplierSpecSchema = z.object({
supplierId: z.string().min(1),
specId: z.string().min(1),
+ billingId: z.string().min(1),
});
const PreparedEventUnionSchema = z.discriminatedUnion("type", [
@@ -63,6 +64,7 @@ function getOperationFromType(type: string): UpsertOperation {
supplierSpec.supplierId,
supplierSpec.specId,
supplierSpec.specId, // use specId for now
+ supplierSpec.billingId, // use billingId for now
);
await deps.letterRepo.putLetter(letterToInsert);
@@ -99,6 +101,7 @@ function mapToInsertLetter(
supplier: string,
spec: string,
billingRef: string,
+ billingId: string,
): InsertLetter {
const now = new Date().toISOString();
return {
@@ -117,6 +120,7 @@ function mapToInsertLetter(
createdAt: now,
updatedAt: now,
billingRef,
+ specificationBillingId: billingId,
};
}
@@ -235,7 +239,11 @@ export default function createUpsertLetterHandler(deps: Deps): SQSHandler {
await runUpsert(
operation,
letterEvent,
- supplierSpec ?? { supplierId: "unknown", specId: "unknown" },
+ supplierSpec ?? {
+ supplierId: "unknown",
+ specId: "unknown",
+ billingId: "unknown",
+ },
deps,
);
diff --git a/package-lock.json b/package-lock.json
index 84ca65626..7724b6332 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -93,6 +93,7 @@
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/lib-dynamodb": "^3.984.0",
"@internal/helpers": "*",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1",
"pino": "^10.3.0",
"zod": "^4.1.11",
"zod-mermaid": "^1.0.9"
@@ -100,7 +101,7 @@
},
"internal/events": {
"name": "@nhsdigital/nhs-notify-event-schemas-supplier-api",
- "version": "1.0.13",
+ "version": "1.0.14",
"license": "MIT",
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
@@ -294,6 +295,7 @@
"@nhsdigital/nhs-notify-event-schemas-letter-rendering": "^2.0.1",
"@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5",
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8",
+ "@nhsdigital/nhs-notify-event-schemas-supplier-config": "^1.0.1",
"@types/aws-lambda": "^8.10.148",
"aws-lambda": "^1.0.7",
"esbuild": "^0.27.2",
@@ -4225,6 +4227,15 @@
"resolved": "internal/events",
"link": true
},
+ "node_modules/@nhsdigital/nhs-notify-event-schemas-supplier-config": {
+ "version": "1.0.1",
+ "resolved": "https://npm.pkg.github.com/download/@nhsdigital/nhs-notify-event-schemas-supplier-config/1.0.1/ff1ce566201ae291825acd5e771537229d6aa9ca",
+ "integrity": "sha512-gIZgfzgvkCfZE+HCosrVJ3tBce2FJRGfwPmtYtZDBG+ox/KvbpJFWXzJ5Jkh/42YzcVn2GxT1fy1L1F6pxiYWA==",
+ "dependencies": {
+ "@asyncapi/bundler": "^0.6.4",
+ "zod": "^4.1.12"
+ }
+ },
"node_modules/@nhsdigital/notify-supplier-api-consumer-contracts": {
"resolved": "pact-contracts",
"link": true
diff --git a/scripts/utilities/letter-test-data/README.md b/scripts/utilities/letter-test-data/README.md
index 284268885..52ed66166 100644
--- a/scripts/utilities/letter-test-data/README.md
+++ b/scripts/utilities/letter-test-data/README.md
@@ -16,6 +16,7 @@ npm run cli -- create-letter \
--letter-id letter-id \
--group-id group-id \
--specification-id specification-id \
+ --billing-id billing-id \
--status PENDING
```
@@ -26,6 +27,7 @@ npm run cli -- create-letter-batch \
--awsAccountId 820178564574 \
--group-id group-id \
--specification-id specification-id \
+ --billing-id billing-id \
--status PENDING \
--count 10
```
diff --git a/scripts/utilities/letter-test-data/src/__test__/helpers/create-letter-helpers.test.ts b/scripts/utilities/letter-test-data/src/__test__/helpers/create-letter-helpers.test.ts
index 9f366d2dd..66af9c01a 100644
--- a/scripts/utilities/letter-test-data/src/__test__/helpers/create-letter-helpers.test.ts
+++ b/scripts/utilities/letter-test-data/src/__test__/helpers/create-letter-helpers.test.ts
@@ -29,6 +29,7 @@ describe("Create letter helpers", () => {
const targetFilename = "targetFilename";
const groupId = "groupId";
const specificationId = "specificationId";
+ const billingId = "billingId";
const status = "PENDING" as LetterStatusType;
const testLetter = "test-letter-standard";
@@ -39,6 +40,7 @@ describe("Create letter helpers", () => {
targetFilename,
groupId,
specificationId,
+ billingId,
status,
letterRepository: mockedLetterRepository,
testLetter,
@@ -62,6 +64,7 @@ describe("Create letter helpers", () => {
source: "/data-plane/letter-rendering/letter-test-data",
subject: "supplier-api/letter-test-data/letterId",
billingRef: "specificationId",
+ specificationBillingId: "billingId",
});
});
@@ -81,6 +84,7 @@ describe("Create letter helpers", () => {
const targetFilename = "targetFilename";
const groupId = "groupId";
const specificationId = "specificationId";
+ const billingId = "billingId";
const status = "PENDING" as LetterStatusType;
const testLetter = "none";
@@ -91,6 +95,7 @@ describe("Create letter helpers", () => {
targetFilename,
groupId,
specificationId,
+ billingId,
status,
letterRepository: mockedLetterRepository,
testLetter,
@@ -110,6 +115,7 @@ describe("Create letter helpers", () => {
billingRef: "specificationId",
source: "/data-plane/letter-rendering/letter-test-data",
subject: "supplier-api/letter-test-data/letterId",
+ specificationBillingId: "billingId",
});
});
@@ -121,6 +127,7 @@ describe("Create letter helpers", () => {
letterId: "testLetterId",
supplierId: "testSupplierId",
specificationId: "testSpecId",
+ billingId: "testBillingId",
groupId: "testGroupId",
status: "PENDING" as LetterStatusType,
url: "s3://bucket/testSupplierId/testLetter.pdf",
@@ -140,6 +147,7 @@ describe("Create letter helpers", () => {
source: "/data-plane/letter-rendering/letter-test-data",
subject: "supplier-api/letter-test-data/testLetterId",
billingRef: "testSpecId",
+ specificationBillingId: "testBillingId",
});
});
});
diff --git a/scripts/utilities/letter-test-data/src/cli/index.ts b/scripts/utilities/letter-test-data/src/cli/index.ts
index 131fe224e..982e9e4ee 100644
--- a/scripts/utilities/letter-test-data/src/cli/index.ts
+++ b/scripts/utilities/letter-test-data/src/cli/index.ts
@@ -44,6 +44,10 @@ async function main() {
type: "string",
demandOption: false,
},
+ "billing-id": {
+ type: "string",
+ demandOption: false,
+ },
"ttl-hours": {
type: "number",
demandOption: false,
@@ -83,6 +87,7 @@ async function main() {
const targetFilename = `${letterId}.pdf`;
const groupId = argv.groupId ?? randomUUID();
const specificationId = argv.specificationId ?? randomUUID();
+ const billingId = argv.billingId ?? randomUUID();
const { status } = argv;
const { environment } = argv;
const { ttlHours } = argv;
@@ -96,6 +101,7 @@ async function main() {
targetFilename,
groupId,
specificationId,
+ billingId,
status: status as LetterStatusType,
letterRepository,
testLetter,
@@ -130,6 +136,10 @@ async function main() {
type: "string",
demandOption: false,
},
+ "billing-id": {
+ type: "string",
+ demandOption: false,
+ },
"ttl-hours": {
type: "number",
demandOption: false,
@@ -174,6 +184,7 @@ async function main() {
const { supplierId } = argv;
const groupId = argv.groupId ?? randomUUID();
const specificationId = argv.specificationId ?? randomUUID();
+ const billingId = argv.billingId ?? randomUUID();
const { status } = argv;
const { environment } = argv;
const { ttlHours } = argv;
@@ -206,6 +217,7 @@ async function main() {
supplierId,
groupId,
specificationId,
+ billingId,
status: status as LetterStatusType,
url,
}),
diff --git a/scripts/utilities/letter-test-data/src/helpers/create-letter-helpers.ts b/scripts/utilities/letter-test-data/src/helpers/create-letter-helpers.ts
index 20131b56c..6278dbbe7 100644
--- a/scripts/utilities/letter-test-data/src/helpers/create-letter-helpers.ts
+++ b/scripts/utilities/letter-test-data/src/helpers/create-letter-helpers.ts
@@ -11,12 +11,14 @@ export async function createLetter(params: {
supplierId: string;
targetFilename: string;
specificationId: string;
+ billingId: string;
groupId: string;
status: LetterStatusType;
letterRepository: LetterRepository;
testLetter: string;
}) {
const {
+ billingId,
bucketName,
groupId,
letterId,
@@ -49,6 +51,7 @@ export async function createLetter(params: {
source: "/data-plane/letter-rendering/letter-test-data",
subject: `supplier-api/letter-test-data/${letterId}`,
billingRef: specificationId,
+ specificationBillingId: billingId,
};
const letterRecord = await letterRepository.putLetter(letter);
@@ -59,12 +62,20 @@ export function createLetterDto(params: {
letterId: string;
supplierId: string;
specificationId: string;
+ billingId: string;
groupId: string;
status: LetterStatusType;
url: string;
}) {
- const { groupId, letterId, specificationId, status, supplierId, url } =
- params;
+ const {
+ billingId,
+ groupId,
+ letterId,
+ specificationId,
+ status,
+ supplierId,
+ url,
+ } = params;
const letter: Omit = {
id: letterId,
@@ -78,6 +89,7 @@ export function createLetterDto(params: {
source: "/data-plane/letter-rendering/letter-test-data",
subject: `supplier-api/letter-test-data/${letterId}`,
billingRef: specificationId,
+ specificationBillingId: billingId,
};
return letter;