-
Notifications
You must be signed in to change notification settings - Fork 5
Use v2 promos table #3327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Use v2 promos table #3327
Changes from all commits
f7f550c
1ba3dc4
1add51f
50423bc
d97f6e7
e2c528a
11a80e3
2840257
ec05426
0bad687
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ( | ||
| 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 = ( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| }; | ||
| 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([ | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps we could make this clearer by inlining this |
||||
|
|
@@ -47,3 +50,9 @@ export const promoSchema = z.object({ | |||
| }); | ||||
|
|
||||
| export type Promo = z.infer<typeof promoSchema>; | ||||
|
|
||||
| export const appliedPromotionSchema = z.object({ | ||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>; | ||||
| 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 = ( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this file stays very close to the v1 implementation: https://github.com/guardian/support-service-lambdas/blob/main/modules/promotions/src/v1/validatePromotion.ts#L21 |
||
| 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 |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
replaces the v1
getPromotionsfunction: https://github.com/guardian/support-service-lambdas/blob/main/modules/promotions/src/v1/getPromotions.ts#L14