handlers/discount-api/src/discountEndpoint.ts (244 lines of code) (raw):
import { sum } from '@modules/arrayFunctions';
import { ValidationError } from '@modules/errors';
import { Lazy } from '@modules/lazy';
import { getIfDefined } from '@modules/nullAndUndefined';
import type { Stage } from '@modules/stage';
import { addDiscount, previewDiscount } from '@modules/zuora/addDiscount';
import {
billingPreviewToSimpleInvoiceItems,
getBillingPreview,
getNextInvoice,
getNextInvoiceItems,
getNextNonFreePaymentDate,
getOrderedInvoiceTotals,
} from '@modules/zuora/billingPreview';
import { zuoraDateFormat } from '@modules/zuora/common';
import { getAccount } from '@modules/zuora/getAccount';
import { getSubscription } from '@modules/zuora/getSubscription';
import type { Logger } from '@modules/zuora/logger';
import { isNotRemovedOrDiscount } from '@modules/zuora/rateplan';
import { ZuoraClient } from '@modules/zuora/zuoraClient';
import type {
ZuoraAccount,
ZuoraSubscription,
} from '@modules/zuora/zuoraSchemas';
import { getZuoraCatalog } from '@modules/zuora-catalog/S3';
import type { APIGatewayProxyEventHeaders } from 'aws-lambda';
import dayjs from 'dayjs';
import { EligibilityChecker } from './eligibilityChecker';
import { generateCancellationDiscountConfirmationEmail } from './generateCancellationDiscountConfirmationEmail';
import { getDiscountFromSubscription } from './productToDiscountMapping';
export const previewDiscountEndpoint = async (
logger: Logger,
stage: Stage,
headers: APIGatewayProxyEventHeaders,
subscriptionNumber: string,
today: dayjs.Dayjs,
) => {
const zuoraClient = await ZuoraClient.create(stage, logger);
const { subscription, account } = await getSubscriptionIfBelongsToIdentityId(
logger.log.bind(logger),
headers,
zuoraClient,
subscriptionNumber,
);
const { discount, dateToApply, orderedInvoiceTotals } =
await getDiscountToApply(
logger,
stage,
subscription,
account,
zuoraClient,
today,
);
logger.log('Preview the new price once the discount has been applied');
// note that this only returns the next payment - payments are not guaranteed to be identical
const previewResponse = await previewDiscount(
zuoraClient,
subscriptionNumber,
dayjs(dateToApply),
discount.productRatePlanId,
);
if (!previewResponse.success || previewResponse.invoiceItems.length < 2) {
throw new Error(
'Unexpected data in preview response from Zuora. ' +
'We expected at least 2 invoice items, one for the discount and at least one for the main plan',
);
}
const discountedPrice = sum(
previewResponse.invoiceItems,
(item) => item.chargeAmount + item.taxAmount,
);
const firstDiscountedPaymentDate = zuoraDateFormat(dayjs(dateToApply));
if (discount.upToPeriodsType !== 'Months') {
throw new Error(
'only discounts measured in months are supported in this version of discount-api',
);
}
const nextNonDiscountedPaymentDate = zuoraDateFormat(
dayjs(dateToApply).add(discount.upToPeriods, 'month'),
);
const nonDiscountedPayments = orderedInvoiceTotals
.map(({ date, total }) => ({
date: zuoraDateFormat(dayjs(date)),
amount: total,
}))
.slice(0, discount.upToPeriods);
return {
discountedPrice,
upToPeriods: discount.upToPeriods,
upToPeriodsType: discount.upToPeriodsType,
discountPercentage: discount.discountPercentage,
firstDiscountedPaymentDate,
nextNonDiscountedPaymentDate,
nonDiscountedPayments,
};
};
export const applyDiscountEndpoint = async (
logger: Logger,
stage: Stage,
headers: APIGatewayProxyEventHeaders,
subscriptionNumber: string,
today: dayjs.Dayjs,
) => {
const zuoraClient = await ZuoraClient.create(stage, logger);
const { subscription, account } = await getSubscriptionIfBelongsToIdentityId(
logger.log.bind(logger),
headers,
zuoraClient,
subscriptionNumber,
);
logger.mutableAddContext(
subscription.ratePlans
.filter(isNotRemovedOrDiscount)
.map((p) => p.ratePlanName)
.join(','),
);
const { discount, dateToApply } = await getDiscountToApply(
logger,
stage,
subscription,
account,
zuoraClient,
today,
);
logger.log('Apply a discount to the subscription');
const discounted = await addDiscount(
zuoraClient,
subscriptionNumber,
dayjs(subscription.termStartDate),
dayjs(subscription.termEndDate),
dayjs(dateToApply),
discount.productRatePlanId,
);
if (!discounted.success) {
throw new Error('discount was not applied: ' + JSON.stringify(discounted));
}
logger.log('Discount applied successfully');
const billingPreviewAfter = await getBillingPreview(
zuoraClient,
dayjs().add(13, 'months'), // 13 months gives us minimum 2 payments even on an Annual sub
subscription.accountNumber,
);
const nextPaymentDate = getNextNonFreePaymentDate(
billingPreviewToSimpleInvoiceItems(billingPreviewAfter),
);
const emailPayload = generateCancellationDiscountConfirmationEmail(
{
firstDiscountedPaymentDate: dayjs(dateToApply),
nextNonDiscountedPaymentDate: discount.name.includes('Free')
? dayjs(nextPaymentDate)
: dayjs(dateToApply).add(discount.upToPeriods, 'month'),
emailAddress: account.billToContact.workEmail,
firstName: account.billToContact.firstName,
lastName: account.billToContact.lastName,
identityId: account.basicInfo.identityId,
},
discount.emailIdentifier,
);
return {
emailPayload,
response: {
nextNonDiscountedPaymentDate: zuoraDateFormat(dayjs(nextPaymentDate)),
},
};
};
async function getSubscriptionIfBelongsToIdentityId(
log: (message: string) => void,
headers: APIGatewayProxyEventHeaders,
zuoraClient: ZuoraClient,
subscriptionNumber: string,
) {
const identityId = getIfDefined(
headers['x-identity-id'],
'Identity ID not found in request',
);
log('Getting the subscription details from Zuora');
const subscription = await getSubscription(zuoraClient, subscriptionNumber);
log('get account for the subscription');
const account = await getAccount(zuoraClient, subscription.accountNumber);
log('assert that sub is owned by logged in user');
if (account.basicInfo.identityId !== identityId) {
throw new ValidationError(
`Subscription ${subscription.subscriptionNumber} does not belong to identity ID ${identityId}`,
);
}
return { subscription, account };
}
async function getDiscountToApply(
logger: Logger,
stage: Stage,
subscription: ZuoraSubscription,
account: ZuoraAccount,
zuoraClient: ZuoraClient,
today: dayjs.Dayjs,
) {
const catalog = () => getZuoraCatalog(stage);
const eligibilityChecker = new EligibilityChecker(logger);
// don't get the billing preview until we know the subscription is not cancelled
const lazyBillingPreview = new Lazy(
() =>
getBillingPreview(
zuoraClient,
today.add(13, 'months'),
subscription.accountNumber,
),
'get billing preview for the subscription',
).then(billingPreviewToSimpleInvoiceItems);
await eligibilityChecker.assertGenerallyEligible(
subscription,
account.metrics.totalInvoiceBalance,
() => lazyBillingPreview.then(getNextInvoiceItems).get(),
);
// now we know the subscription is not cancelled we can force the billing preview
const billingPreview = await lazyBillingPreview.get();
logger.log('Working out the appropriate discount for the subscription');
const { discount, discountableProductRatePlanId } =
getDiscountFromSubscription(stage, subscription);
logger.log('Checking this subscription is eligible for the discount');
switch (discount.eligibilityCheckForRatePlan) {
case 'EligibleForFreePeriod':
eligibilityChecker.assertEligibleForFreePeriod(
discount.productRatePlanId,
subscription,
today,
);
break;
case 'AtCatalogPrice':
eligibilityChecker.assertNextPaymentIsAtCatalogPrice(
await catalog(),
billingPreview,
discountableProductRatePlanId,
account.metrics.currency,
);
break;
case 'NoRepeats':
eligibilityChecker.assertNoRepeats(
discount.productRatePlanId,
subscription,
);
break;
case 'NoCheck':
break;
}
const dateToApply = getNextInvoice(billingPreview).date;
const orderedInvoiceTotals = getOrderedInvoiceTotals(billingPreview);
return { discount, dateToApply, orderedInvoiceTotals };
}