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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions modules/promotions/src/v2/getPromotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb';
import { unmarshall } from '@aws-sdk/util-dynamodb';
import { awsConfig } from '@modules/aws/config';
import type {
ProductCatalog,
ProductKey,
} from '@modules/product-catalog/productCatalog';
import { supportsPromotions } from '@modules/product-catalog/productCatalog';
import type { Stage } from '@modules/stage';
import type { Promo } from '@modules/promotions/v2/schema';
import { promoSchema } from '@modules/promotions/v2/schema';

const dynamoClient = new DynamoDBClient(awsConfig);

export const getPromotion = async (
Copy link
Member Author

@tomrf1 tomrf1 Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

promoCode: string,
stage: Stage,
): Promise<Promo> => {
const tableName = `support-admin-console-promos-${stage}`;
console.log(`Querying ${tableName}`);
const result = await dynamoClient.send(
new GetItemCommand({
TableName: tableName,
Key: { promoCode: { S: promoCode } },
}),
);

if (result.Item === undefined) {
throw new ReferenceError(
`We were unable to retrieve promotions from ${tableName}`,
);
}

const unmarshalledItem = unmarshall(result.Item);
const parseResult = promoSchema.safeParse(unmarshalledItem);

if (!parseResult.success) {
console.error(
`Failed to parse promotion: ${JSON.stringify(result.Item, null, 2)} because of error:`,
parseResult.error,
);
throw new Error('Failed to parse promotion');
}
return parseResult.data;
};

export const getDiscountRatePlanFromCatalog = (
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copied from v1/getPromotions.ts

productCatalog: ProductCatalog,
productKey: ProductKey,
) => {
if (supportsPromotions(productKey)) {
return productCatalog[productKey].ratePlans.Discount;
}
return;
};
11 changes: 10 additions & 1 deletion modules/promotions/src/v2/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { isoCountrySchema } from '@modules/internationalisation/schemas';
import {
isoCountrySchema,
supportRegionSchema,
} from '@modules/internationalisation/schemas';
import { z } from 'zod';

export const promoProductSchema = z.enum([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise this was not introduced in this PR but we should be using the product catalog values for this.

There is a schema called productKeySchema which lists these here:

export const productKeySchema = z.enum(productKeys);

Possibly we could add another schema which is a subset of these as not all will be discountable, but we should really try and work with the same product names across our whole code base.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The complication is that in the promo tool the concept of PromoCampaign groups several product rate plans into one "product", e.g. Newspaper.
This promoProductSchema is only relevant to PromoCampaigns. At the level of individual promo codes we map direct to a set of rate plan ids

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could make this clearer by inlining this enum in the promoCampaignSchema

Expand Down Expand Up @@ -47,3 +50,9 @@ export const promoSchema = z.object({
});

export type Promo = z.infer<typeof promoSchema>;

export const appliedPromotionSchema = z.object({
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copied from the v1 schema

promoCode: z.string(),
supportRegionId: supportRegionSchema,
});
export type AppliedPromotion = z.infer<typeof appliedPromotionSchema>;
105 changes: 105 additions & 0 deletions modules/promotions/src/v2/validatePromotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { intersection } from '@modules/arrayFunctions';
import { ValidationError } from '@modules/errors';
import type { SupportRegionId } from '@modules/internationalisation/countryGroup';
import { countryGroupBySupportRegionId } from '@modules/internationalisation/countryGroup';
import { logger } from '@modules/routing/logger';
import type { AppliedPromotion, Promo } from './schema';

export type ValidatedPromotion = {
discountPercentage: number;
durationInMonths?: number;
promoCode: string;
};

export const validatePromotion = (
Copy link
Member Author

@tomrf1 tomrf1 Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

promotion: Promo | undefined,
appliedPromotion: AppliedPromotion,
productRatePlanId: string,
): ValidatedPromotion => {
if (!promotion) {
throw new ValidationError(
`No Promotion found for promo code ${appliedPromotion.promoCode}`,
);
}
logger.log(`Validating promotion ${promotion.promoCode}: `, promotion.name);

checkPromotionIsActive(promotion);
console.log(`${promotion.promoCode} is active`);

checkHasDiscount(promotion);
console.log(
`${promotion.promoCode} has a duration of ${promotion.discount.durationMonths}`,
);

validateForCountryGroup(promotion, appliedPromotion.supportRegionId);
console.log(
`Promotion ${promotion.promoCode} is valid for country group ${appliedPromotion.supportRegionId}`,
);

validateProductRatePlan(promotion, productRatePlanId);
console.log(
`Promotion ${promotion.promoCode} is valid for product rate plan ${productRatePlanId}`,
);

return {
discountPercentage: promotion.discount.amount,
durationInMonths: promotion.discount.durationMonths,
promoCode: promotion.promoCode,
};
};

const checkPromotionIsActive = (promotion: Promo) => {
const now = new Date();
const startDate = new Date(promotion.startTimestamp);
const endDate = promotion.endTimestamp
? new Date(promotion.endTimestamp)
: null;

if (startDate > now) {
throw new ValidationError(
`Promotion ${promotion.name} is not yet active, starts on ${startDate.toISOString()}`,
);
}
if (endDate && endDate <= now) {
throw new ValidationError(
`Promotion ${promotion.name} expired on ${endDate.toISOString()}`,
);
}
};

function checkHasDiscount(
promotion: Promo,
): asserts promotion is Promo & { discount: NonNullable<Promo['discount']> } {
if (promotion.discount === undefined) {
throw new ValidationError(
`Promotion ${promotion.promoCode} is missing discount`,
);
}
}

const validateForCountryGroup = (
promotion: Promo,
supportRegionId: SupportRegionId,
) => {
const countryGroup = countryGroupBySupportRegionId(supportRegionId);

if (
intersection([...promotion.appliesTo.countries], countryGroup.countries)
.length === 0
) {
throw new ValidationError(
`Promotion ${promotion.name} is not valid for country group ${countryGroup.name}`,
);
}
};

const validateProductRatePlan = (
promotion: Promo,
productRatePlanId: string,
) => {
if (!promotion.appliesTo.productRatePlanIds.includes(productRatePlanId)) {
throw new ValidationError(
`Promotion ${promotion.name} is not valid for product rate plan ${productRatePlanId}`,
);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import * as util from 'node:util';
import { getIfDefined } from '@modules/nullAndUndefined';
import { getPromotions } from '../src/v1/getPromotions';
import { getPromotions } from '@modules/promotions/v1/getPromotions';

describe('getPromotions functions', () => {
test('we can return all promotions for a given stage', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { SupportRegionId } from '@modules/internationalisation/countryGroup';
import type { AppliedPromotion, Promotion } from '../src/v1/schema';
import { validatePromotion } from '../src/v1/validatePromotion';
import type {
AppliedPromotion,
Promotion,
} from '@modules/promotions/v1/schema';
import { validatePromotion } from '@modules/promotions/v1/validatePromotion';

const promotionName = 'Test Promotion';
const productRatePlanId = '12345';
Expand Down
120 changes: 120 additions & 0 deletions modules/promotions/test/v2/validationPromotion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { SupportRegionId } from '@modules/internationalisation/countryGroup';
import type { AppliedPromotion, Promo } from '@modules/promotions/v2/schema';
import { validatePromotion } from '@modules/promotions/v2/validatePromotion';

const promotionName = 'Test Promotion';
const productRatePlanId = '12345';
const promoCode = 'TEST123';

const testPromotion: Promo = {
name: promotionName,
promoCode: promoCode,
campaignCode: 'campaign',
startTimestamp: '2024-09-25T23:00:00.000Z',
endTimestamp: '2099-11-05T23:59:59.000Z',
discount: {
amount: 25,
durationMonths: 3,
},
appliesTo: {
countries: ['GB'],
productRatePlanIds: [productRatePlanId],
},
};

const appliedPromotion: AppliedPromotion = {
promoCode,
supportRegionId: SupportRegionId.UK,
};

describe('validatePromotion v2', () => {
it('throws an error if the promotion is undefined', () => {
expect(() =>
validatePromotion(undefined, appliedPromotion, productRatePlanId),
).toThrow(
`No Promotion found for promo code ${appliedPromotion.promoCode}`,
);
});

it('returns a ValidatedPromotion for valid promotion and country', () => {
expect(
validatePromotion(testPromotion, appliedPromotion, productRatePlanId),
).toStrictEqual({
discountPercentage: 25,
durationInMonths: 3,
promoCode: promoCode,
});
});

it('throws an error for invalid productRatePlanId', () => {
expect(() =>
validatePromotion(
testPromotion,
appliedPromotion,
'invalidProductRatePlanId',
),
).toThrow(
`Promotion ${promotionName} is not valid for product rate plan invalidProductRatePlanId`,
);
});

it('throws an error for invalid countryGroupId', () => {
expect(() =>
validatePromotion(
testPromotion,
{ promoCode, supportRegionId: SupportRegionId.EU },
productRatePlanId,
),
).toThrow(
`Promotion ${promotionName} is not valid for country group Europe`,
);
});

it('throws an error if the discount is missing', () => {
const noDiscountPromotion: Promo = {
...testPromotion,
discount: undefined,
};
expect(() =>
validatePromotion(
noDiscountPromotion,
appliedPromotion,
productRatePlanId,
),
).toThrow(`Promotion ${promoCode} is missing discount`);
});

it('throws an error if the promotion has not started yet', () => {
const futureDate = '2099-09-25T00:00:00.000Z';
const futurePromotion: Promo = {
...testPromotion,
startTimestamp: futureDate,
};
expect(() =>
validatePromotion(futurePromotion, appliedPromotion, productRatePlanId),
).toThrow(
`Promotion ${promotionName} is not yet active, starts on ${futureDate}`,
);
});

it('throws an error if the promotion has expired', () => {
const pastDate = '2000-09-25T00:00:00.000Z';
const expiredPromotion: Promo = {
...testPromotion,
endTimestamp: pastDate,
};
expect(() =>
validatePromotion(expiredPromotion, appliedPromotion, productRatePlanId),
).toThrow(`Promotion ${promotionName} expired on ${pastDate}`);
});

it('does not throw if endTimestamp is undefined', () => {
const noEndPromotion: Promo = {
...testPromotion,
endTimestamp: undefined,
};
expect(() =>
validatePromotion(noEndPromotion, appliedPromotion, productRatePlanId),
).not.toThrow();
});
});
23 changes: 10 additions & 13 deletions modules/zuora/src/createSubscription/createSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@ import type {
ProductKey,
} from '@modules/product-catalog/productCatalog';
import type { ProductPurchase } from '@modules/product-catalog/productPurchaseSchema';
import { getDiscountRatePlanFromCatalog } from '@modules/promotions/v1/getPromotions';
import type {
AppliedPromotion,
Promotion,
} from '@modules/promotions/v1/schema';
import type { ValidatedPromotion } from '@modules/promotions/v1/validatePromotion';
import { validatePromotion } from '@modules/promotions/v1/validatePromotion';
import { getDiscountRatePlanFromCatalog } from '@modules/promotions/v2/getPromotion';
import type { AppliedPromotion, Promo } from '@modules/promotions/v2/schema';
import type { ValidatedPromotion } from '@modules/promotions/v2/validatePromotion';
import { validatePromotion } from '@modules/promotions/v2/validatePromotion';
import dayjs from 'dayjs';
import { z } from 'zod';
import { getChargeOverride } from '@modules/zuora/createSubscription/chargeOverride';
Expand Down Expand Up @@ -69,13 +66,13 @@ export type PromotionInputFields = {

export function getPromotionInputFields(
appliedPromotion: AppliedPromotion | undefined,
promotions: Promotion[],
promotion: Promo | undefined,
productRatePlanId: string,
productCatalog: ProductCatalog,
productKey: ProductKey,
): PromotionInputFields | undefined {
const validatedPromotion = appliedPromotion
? validatePromotion(promotions, appliedPromotion, productRatePlanId)
? validatePromotion(promotion, appliedPromotion, productRatePlanId)
: undefined;

if (!validatedPromotion) {
Expand All @@ -98,7 +95,6 @@ export function getPromotionInputFields(

export function buildCreateSubscriptionRequest<T extends PaymentMethod>(
productCatalog: ProductCatalog,
promotions: Promotion[],
{
accountName,
createdRequestId,
Expand All @@ -115,6 +111,7 @@ export function buildCreateSubscriptionRequest<T extends PaymentMethod>(
runBilling,
collectPayment,
}: CreateSubscriptionInputFields<T>,
promotion: Promo | undefined,
): CreateOrderRequest {
const { deliveryContact, deliveryAgent, deliveryInstructions } = {
deliveryContact: undefined,
Expand Down Expand Up @@ -153,7 +150,7 @@ export function buildCreateSubscriptionRequest<T extends PaymentMethod>(
const productRatePlan = getProductRatePlan(productCatalog, productPurchase);
const promotionInputFields = getPromotionInputFields(
appliedPromotion,
promotions,
promotion,
productRatePlan.id,
productCatalog,
productPurchase.product,
Expand Down Expand Up @@ -196,12 +193,12 @@ export function buildCreateSubscriptionRequest<T extends PaymentMethod>(
export const createSubscription = async <T extends PaymentMethod>(
zuoraClient: ZuoraClient,
productCatalog: ProductCatalog,
promotions: Promotion[],
inputFields: CreateSubscriptionInputFields<T>,
promotion: Promo | undefined,
): Promise<CreateSubscriptionResponse> => {
return executeOrderRequest(
zuoraClient,
buildCreateSubscriptionRequest(productCatalog, promotions, inputFields),
buildCreateSubscriptionRequest(productCatalog, inputFields, promotion),
createSubscriptionResponseSchema,
inputFields.createdRequestId,
);
Expand Down
Loading