shared/productResponse.ts (300 lines of code) (raw):
import * as Sentry from '@sentry/browser';
import * as React from 'react';
import type { CurrencyIso } from '@/client/utilities/currencyIso';
import type { DeliveryRecordDetail } from '../client/components/mma/delivery/records/deliveryRecordsApi';
import { AsyncLoader } from '../client/components/mma/shared/AsyncLoader';
import type { CardProps } from '../client/components/mma/shared/CardDisplay';
import type { DirectDebitGatewayOwner } from './directDebit';
import { PRODUCT_TYPES } from './productTypes';
import type { ProductType } from './productTypes';
export type DeliveryRecordApiItem = DeliveryRecordDetail;
export type MembersDataApiUser = {
firstName: string;
lastName: string;
email: string;
};
export type MembersDataApiResponse = {
user?: MembersDataApiUser;
products: MembersDataApiItem[];
};
export type MembersDataApiItem = ProductDetail | object;
export type SingleProductDetail = {
created: number;
currency: string;
price: number;
status: string;
};
export interface InvoiceDataApiItem {
invoiceId: string;
subscriptionName: string;
date: string;
pdfPath: string;
price?: number;
amount?: number;
paymentMethod: string;
hasMultipleSubs: boolean;
last4?: string;
cardType?: string;
}
export class MembersDataApiAsyncLoader extends AsyncLoader<MembersDataApiResponse> {}
export const MembersDataApiItemContext: React.Context<MembersDataApiItem> =
React.createContext({});
export const formatDate = (shortForm: string) => {
return new Date(shortForm).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
};
export const MDA_TEST_USER_HEADER = 'X-Gu-Membership-Test-User';
export const sortByJoinDate = (a: ProductDetail, b: ProductDetail) =>
b.joinDate.localeCompare(a.joinDate);
export type PhoneRegionKey = 'US' | 'AUS' | 'UK & ROW';
export interface SelfServiceCancellation {
isAllowed: boolean;
shouldDisplayEmail: boolean;
phoneRegionsToDisplay: PhoneRegionKey[];
}
export const productTiers = [
'guardianpatron',
'Tier Three',
'Digital Pack',
'Newspaper - National Delivery',
'Supporter',
'Supporter Plus',
'Guardian Weekly - ROW',
'Guardian Weekly - Domestic',
'Newspaper Digital Voucher',
'Contributor',
'Guardian Weekly Zone A',
'Guardian Weekly Zone B',
'Guardian Weekly Zone C',
'Newspaper Voucher',
'Newspaper Delivery',
'Patron',
'Partner',
'Guardian Ad-Lite',
'Newspaper Delivery - Observer',
'Newspaper Digital Voucher - Observer',
];
export type ProductTier = typeof productTiers[number];
export interface ProductDetail extends WithSubscription {
isTestUser: boolean; // THIS IS NOT PART OF THE members-data-api RESPONSE (but inferred from a header)
isPaidTier: boolean;
regNumber?: string;
optIn?: boolean;
key?: string;
tier: ProductTier;
joinDate: string;
alertText?: string;
selfServiceCancellation: SelfServiceCancellation;
billingCountry?: string;
}
export interface CancelledProductDetail {
tier: ProductTier;
joinDate: string;
subscription: CancelledSubscription;
}
export function isProduct(
data: MembersDataApiItem | undefined,
): data is ProductDetail {
return productTiers.includes((data as ProductDetail)?.tier);
}
export const isObserverProduct = (productDetail: ProductDetail): boolean => {
return (
productDetail.tier === 'Newspaper Delivery - Observer' ||
productDetail.tier === 'Newspaper Digital Voucher - Observer'
);
};
export interface Card extends CardProps {
stripePublicKeyForUpdate: string;
email?: string;
}
export interface DirectDebitDetails {
accountName: string;
accountNumber: string;
sortCode: string;
gatewayOwner?: DirectDebitGatewayOwner;
}
export interface SubscriptionPlan {
name: string | null;
start?: string;
shouldBeVisible: boolean;
daysOfWeek?: string[];
}
interface SepaDetails {
accountName: string;
iban: string;
}
export type BillingPeriod = 'month' | '6 weeks' | 'quarter' | 'year';
interface CurrencyAndBillingPeriodDetail {
currency: string;
currencyISO: CurrencyIso;
billingPeriod: BillingPeriod;
}
// 6 weeks billingPeriod referes to GW 6 for 6 up front payment (not to be confused with one off contributions which don't come through in this response
export const augmentBillingPeriod = (billingPeriod: BillingPeriod) =>
billingPeriod === '6 weeks' ? 'one-off' : `${billingPeriod}ly`;
export const isSixForSix = (planName: string | null) =>
!!planName && planName.includes('6 for 6');
export interface PaidSubscriptionPlan
extends SubscriptionPlan,
CurrencyAndBillingPeriodDetail {
start: string;
end: string;
chargedThrough?: string | null;
price: number;
features: string;
}
export function isPaidSubscriptionPlan(
subscriptionPlan: SubscriptionPlan,
): subscriptionPlan is PaidSubscriptionPlan {
return (
!!subscriptionPlan &&
(subscriptionPlan.hasOwnProperty('price') ||
subscriptionPlan.hasOwnProperty('amount'))
);
}
export interface DeliveryAddress {
addressLine1: string;
addressLine2?: string;
town?: string;
region?: string;
postcode: string;
country: string;
instructions?: string;
addressChangeInformation?: string;
}
type ReaderType = 'Gift' | 'Direct' | 'Agent' | 'Complementary' | 'Patron';
export interface Subscription {
accountId?: string;
subscriptionId: string;
start?: string;
end: string;
renewalDate: string;
anniversaryDate: string;
cancelledAt: boolean;
nextPaymentDate: string | null;
lastPaymentDate: string | null;
potentialCancellationDate: string | null;
chargedThroughDate: string | null;
nextPaymentPrice: number | null;
paymentMethod?: string;
stripePublicKeyForCardAddition?: string;
safeToUpdatePaymentMethod: boolean;
card?: Card;
payPalEmail?: string;
mandate?: DirectDebitDetails;
sepaMandate?: SepaDetails;
autoRenew: boolean;
currentPlans: Array<SubscriptionPlan | PaidSubscriptionPlan>;
futurePlans: Array<SubscriptionPlan | PaidSubscriptionPlan>;
plan?: PaidSubscriptionPlan; // this is used for memberships (remove when memberships no longer exist)
trialLength: number;
readerType: ReaderType;
deliveryAddress?: DeliveryAddress;
contactId?: string;
account?: {
accountName: string;
};
// THIS IS NOT PART OF THE members-data-api RESPONSE (it's injected server-side - see server/routes/api.ts)
deliveryAddressChangeEffectiveDate?: string;
cancellationEffectiveDate?: string;
}
interface CancelledSubscription {
subscriptionId: string;
cancellationEffectiveDate: string;
start: string;
end: string;
readerType: ReaderType;
accountId: string;
}
export interface SubscriptionWithDeliveryAddress extends Subscription {
deliveryAddress: DeliveryAddress;
}
export interface WithSubscription {
subscription: Subscription;
}
export const isGift = (subscription: { readerType: string }) =>
subscription.readerType === 'Gift';
export const getMainPlan: (subscription: Subscription) => SubscriptionPlan = (
subscription: Subscription,
) => {
if (subscription.currentPlans.length > 0) {
if (subscription.currentPlans.length > 1) {
Sentry.captureException(
"User with more than one 'current plan' for a given subscription",
);
}
return subscription.currentPlans[0];
} else if (subscription.futurePlans.length > 0) {
// fallback to use the first future plan (contributions for example are always future plans)
return subscription.futurePlans[0];
}
return {
name: null,
start: subscription.start,
shouldBeVisible: true,
currency: subscription.plan?.currency,
currencyISO: subscription.plan?.currencyISO,
billingPeriod: subscription.plan?.billingPeriod,
};
};
export function getSpecificProductType(productTier: ProductTier): ProductType {
let productType: ProductType = {} as ProductType;
switch (productTier) {
case 'Partner':
case 'Patron':
case 'Supporter':
productType = PRODUCT_TYPES.membership;
break;
case 'Contributor':
productType = PRODUCT_TYPES.contributions;
break;
case 'Tier Three':
productType = PRODUCT_TYPES.tierthree;
break;
case 'Newspaper Voucher':
productType = PRODUCT_TYPES.voucher;
break;
case 'Digital Pack':
productType = PRODUCT_TYPES.digipack;
break;
case 'Newspaper Delivery':
productType = PRODUCT_TYPES.homedelivery;
break;
case 'Supporter Plus':
productType = PRODUCT_TYPES.supporterplus;
break;
case 'Newspaper Digital Voucher':
productType = PRODUCT_TYPES.digitalvoucher;
break;
case 'Guardian Ad-Lite':
productType = PRODUCT_TYPES.guardianadlite;
break;
case 'guardianpatron':
productType = PRODUCT_TYPES.guardianpatron;
break;
case 'Guardian Weekly Zone A':
case 'Guardian Weekly Zone B':
case 'Guardian Weekly Zone C':
case 'Guardian Weekly - ROW':
case 'Guardian Weekly - Domestic':
productType = PRODUCT_TYPES.guardianweekly;
break;
case 'Newspaper - National Delivery':
productType = PRODUCT_TYPES.nationaldelivery;
break;
case 'Newspaper Delivery - Observer':
productType = PRODUCT_TYPES.observer;
break;
case 'Newspaper Digital Voucher - Observer':
productType = PRODUCT_TYPES.digitalvoucherobserver;
break;
}
return productType;
}
export function isSpecificProductType(
productDetail: ProductDetail,
targetProductType: ProductType,
): boolean {
const specificProductType = getSpecificProductType(productDetail.tier);
return specificProductType === targetProductType;
}