import { URL, URLSearchParams } from 'url'; import { Base } from './base'; import { UrlDetails } from './types/request'; import { CreateSubscriptionResponse, PaymentGatewayResponse, PaymentSignatureResponse, PassthroughFieldsResponse } from './types/response'; import { DeliveryAddress, PaymentOptions, PaymentSignature, PaymentType, ExposedSubscriptionHistory, FetchGatewayConfigOptions } from './types/subscription'; import { PaymentUserDetails, PaypalPaymentDetails } from './types/payment'; import { User } from './models/user'; import { Status200Error, ValidationError, EmptyResultError } from './errors'; import { mapPaymentOptions } from './mappers/payment-options'; import { mapSubscription, mapSubscriptionHistory } from './mappers/subscription'; import { Subscription as SubscriptionModel } from './models/subscription'; export class Subscription extends Base { private membershipApi = this.config.get('membershipApi'); private subscriptionApiKey = this.config.get('subscriptionApiKey'); /** * This function is the public method for retrieving the user's subscription history * @param userId The id of the user whose subscription history to fetch * @returns Subscription information and history. */ public async getSubscriptionHistoryByUserId(userId: string): Promise { return this.getSubscriptionHistoryFromUrl({ query: `userId=${userId}` }); } /** * This function is the public method for retrieving the user's subscription history * @param subscriptionNumber The unique subscriptionNumber for the subscription whose history to fetch * @returns Subscription information and history. */ public async getSubscriptionHistoryById(subscriptionNumber: string): Promise { return this.getSubscriptionHistoryFromUrl({ path: `/${subscriptionNumber}` }); } /** * This function is a private utility method for retrieving the user's subscription history * @returns Subscription information and history. * @param urlDetails */ private async getSubscriptionHistoryFromUrl(urlDetails: UrlDetails): Promise { const subscriptionHistoryApiKey = this.config.get('subscriptionHistoryApiKey'); const additionalHeaders = { 'Cache-Control': 'no-cache' }; const url = new URL('/membership/subs-history/subscriptions/history', this.membershipApi); if (urlDetails.path) { url.pathname = url.pathname + urlDetails.path; } if (urlDetails.query) { url.search = urlDetails.query; } const items = await this.requestGet({ url: url.href, key: subscriptionHistoryApiKey, additionalHeaders }); return mapSubscriptionHistory(items); } /** * Create a subscription for the user on the given offer using the payment and delivery information * @param user Full user object retrieved from Graphql.getUserDetails * @param paymentOptions Offer and Payment information * @param deviceId Ravelin cookie deviceId generated by Ravelin script * @param deliveryAddress If the offer is deliverable then deliveryAddress must be supplied * @param ravelinBypassId Optional. By specifying the correct Id in this field, it indicates that Ravelin verification needs to be bypassed on the membership end. * @throws {ValidationError} When paymentOptions.billingCountry is not supplied * @throws {ValidationError} When an offer is either print or bundle and the deliveryAddress is not supplied * @throws {ValidationError} When paymentOptions.paypalBAID is not supplied and paymentOptions.paymentType is PaymentType.PAYPAL * @throws {ValidationError} When paymentOptions.paypalEmail is not supplied and paymentOptions.paymentType is PaymentType.PAYPAL * @throws {ValidationError} When paymentOptions.billingCountry is USA or CAN and paymentOptions.billingState is not provided * @throws {ValidationError} When paymentOptions.billingCountry is USA or CAN and paymentOptions.billingPostcode is not provided * @returns {subscriptionId, subscriptionNumber, invoiceNumber, invoiceId} */ public async createSubscription(user: User, paymentOptions: PaymentOptions, deviceId: string, deliveryAddress?: DeliveryAddress, ravelinBypassId?: string): Promise { const url = `${this.membershipApi}/subscriptions/subscribe`; if (!paymentOptions.billingCountry) { throw new ValidationError('Billing country code is not defined', { country: paymentOptions.billingCountry }); } // Retrieve the correct Apple Pay gateway name from membership if (paymentOptions.paymentType === PaymentType.APPLEPAY) { paymentOptions.paymentGateway = await this.fetchGatewayName(PaymentType.APPLEPAY, paymentOptions.billingCountry); } // Set the correct PayPal paymentGateway from the configuration if (paymentOptions.paymentType === PaymentType.PAYPAL) { if (!paymentOptions.paypalBAID || !paymentOptions.paypalEmail) { throw new ValidationError('Payment Options paypalBAID and paypalEmail are required for paymentType paypal'); } paymentOptions.paymentGateway = this.config.get('paypalPaymentGateway'); } // Set header to bypass ravelin in case is required // it depends on a secret checked in next-subscribe // user.ravelinBypassId=undefined means didn't match the secret or was not required const additionalHeaders = Boolean(ravelinBypassId) ? { 'Ravelin-Bypass-Id': ravelinBypassId } : {}; const body: any = { subscribe: { user: { userId: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, countryCode: paymentOptions.billingCountry }, ravelinData: { deviceId: deviceId, }, options: mapPaymentOptions(paymentOptions) } }; if (!paymentOptions.offer.isEpaper() && (paymentOptions.offer.isPrint() || paymentOptions.offer.isBundle())) { if (!deliveryAddress) { throw new ValidationError('Delivery Address must be supplied for this offer'); } body.subscribe.deliveryAddress = { address1: deliveryAddress.addressType === 'pobox' ? deliveryAddress.poBox : deliveryAddress.addressLine1, address2: deliveryAddress.addressLine2, address3: deliveryAddress.addressLine3, city: deliveryAddress.city, countryCode: deliveryAddress.countryCode, county: deliveryAddress.county, notes: deliveryAddress.instructions, postCode: deliveryAddress.postcode, state: deliveryAddress.state || deliveryAddress.province, company: deliveryAddress.company, poBox: deliveryAddress.addressType === 'pobox' }; } if (paymentOptions.billingCountry === 'USA' || paymentOptions.billingCountry === 'CAN') { if (!paymentOptions.billingState || !paymentOptions.billingPostcode) { throw new ValidationError('For USA and CAN please provide billingState and billingPostcode'); } body.subscribe.user.state = paymentOptions.billingState; body.subscribe.user.postCode = paymentOptions.billingPostcode; } if (paymentOptions.offer.isEpaper()) { body.subscribe.user.postCode = paymentOptions.billingPostcode; body.subscribe.user.city = paymentOptions.billingCity; } const response = await this.requestPost({ url, key: this.subscriptionApiKey, body, additionalHeaders }); if (this.isMembershipError(response)) { throw new Status200Error('createSubscriptionError', response); } return response; } /** * Membership endpoints can return http 200 responses with body `{errors:[...]}` * @param response */ private isMembershipError(response: object): boolean { const keys = Object.keys(response); return (keys.length === 1 && keys[0] === 'errors'); } /** * This function is the public method for fetching the Zuora payment gateway iframe config. * You can find there Zuora Dev API docs here: https://www.zuora.com/developer/api-reference/#operation/POST_CreateAuthorization * @param configOptions * @returns payment gateway configuration options */ public async fetchGatewayConfig(configOptions: FetchGatewayConfigOptions): Promise { const { appIdOverride, userId, paymentType, countryCode, clientIPAddress, authorizationAmount, ftSession='', paymentTerm='' } = configOptions; // Fetching appId here as it's not required for the whole service const appId = appIdOverride || this.config.get('subscriptionApiAppId'); const queryParams = new URLSearchParams({ userId }); if (paymentTerm) { // this needs to be passed in to support recurring transactions in India // if provided, FT Core will include additional field in the response payload // Epic: https://financialtimes.atlassian.net/browse/SUB-962 queryParams.append('billingFrequency', paymentTerm); } const url = new URL(`${this.membershipApi}/paymentpage2/config/${paymentType}/${appId}/${countryCode}`); url.search = queryParams.toString(); const gatewayResponse = await this.requestGet({ url: url.toString(), key: this.subscriptionApiKey }); const baseConfig: Record = { id: gatewayResponse.pageId, key: gatewayResponse.authData.key, style: 'inline', token: gatewayResponse.authData.token, tenantId: gatewayResponse.authData.tenantId, signature: gatewayResponse.authData.signature, submitEnabled: 'false', paymentGateway: gatewayResponse.paymentGateway, url: gatewayResponse.hostedPaymentPageUrl, field_currency: gatewayResponse.currencyCode || '', authorizationAmount: authorizationAmount, }; const passthroughFields = this.getPassthroughFields(gatewayResponse, userId, appId, countryCode, ftSession); const gatewayDataConfig: Record= {}; const gatewayData = gatewayResponse.gatewayData; if (gatewayData) { // When the membership endpoint returns a key for `param_gwOptions_customer_ip` (with an empty value) // it means that the gateway expects the customer IP address to be passed and we need to update the value. if ('param_gwOptions_customer_ip' in gatewayData) { gatewayData.param_gwOptions_customer_ip = clientIPAddress; } // param_gwOptions_purchaseTotals_currency should always be set and takes precedence over currencyCode if (gatewayData.param_gwOptions_purchaseTotals_currency) { baseConfig.field_currency = gatewayData.param_gwOptions_purchaseTotals_currency; } for (let key in gatewayData) { gatewayDataConfig[key] = gatewayData[key]; } } const zuoraConfig = { ...baseConfig, ...gatewayDataConfig, ...passthroughFields }; return zuoraConfig; } /** * passthrough fields are sent back to us when zuora calls our callback endpoint * this will allow for some added validation both in membership and customer-products * this is hopefully temporary and we'll request native fields to zuora * they will likely move/change order depending on what we end up using * * @param gatewayResponse Response from membership * @param userId * @param appId zuora app id (NEXTSUBSCRIBE, NEXTPRROFILE) * @param countryCode * @param ftSession FTSession_s */ getPassthroughFields(gatewayResponse: Record, userId: string, appId: string, countryCode: string, ftSession: string): PassthroughFieldsResponse { const passthroughFields = { // zuora page id, one to one match with zuoraAppId field_passthrough1: gatewayResponse.pageId, field_passthrough2: userId, // zuora app is, used by customer products apps to point to a given zuora configuration field_passthrough3: appId, // we don't have the gateway name on the callback otherwise field_passthrough4: gatewayResponse.paymentGateway, field_passthrough5: ftSession, // passing the country/currency code to reconcile and // be able to check that we have correct values field_passthrough6: countryCode, field_passthrough7: gatewayResponse.currencyCode, }; return passthroughFields; } /** * When using some payment types the Payment Gateway name must be sent to membership * This gets the correct name for a particular payment type and country code * @param paymentType Lowercase string of type being used i.e. applepay * @param countryCode Three letter ISO 3166-1 country code i.e. GBR * @returns Name of the payment gateway */ public async fetchGatewayName(paymentType: string, countryCode: string): Promise { const url = `${this.membershipApi}/payment-gateway/${paymentType.toUpperCase()}/${countryCode.toUpperCase()}`; const response = await this.requestGet({ url, key: this.subscriptionApiKey }); if (!response.gateway) { throw new Error(`Gateway name for payment type ${paymentType} and country code ${countryCode} does not exist`); } return response.gateway; } /** * This function is the public method that validates the payment signature returned from a Zuora response * @param signatureOptions Data needed by membership to validate the signature * @returns response status */ public async validatePaymentSignature(signatureOptions: PaymentSignature): Promise { // Manually calling this rather than using requestPost as this endpoint returns a 200 and an empty response if valid. ❄️ const response = await this.fetch(`${this.membershipApi}/paymentpage2/validation/signature`, { method: 'POST', headers: { 'X-api-key': this.subscriptionApiKey, 'Content-Type': 'application/json', }, body: JSON.stringify(signatureOptions) }); return { status: response.status, ok: response.ok }; } /** * This function allows the user to update their default payment method. * @param userId User's ID * @param newPaymentMethodId Obtained by submitting the Zuora iframe and receiving the new ID (refId query param). * @param paymentGateway The name of the Gateway used to create the payment method ID. * @param isPayNow a boolean to tell membership the user will pay now */ public async updateDefaultPaymentMethod(userId: string, newPaymentMethodId: string, paymentGateway?: string, isPayNow:boolean=false): Promise { const baseURL = `${this.membershipApi}/set-default-payment/${newPaymentMethodId}?userId=${userId}`; const url = new URL(baseURL); if (paymentGateway) { url.searchParams.set('paymentGateway', paymentGateway); } if (isPayNow) { url.searchParams.set('isPayNow', isPayNow.toString()); } await this.requestPut({ url: url.toString(), key: this.subscriptionApiKey }); } /** * This function allows the user to create a new payment method on Zuora. * Will need to be extended in future if we want to support other payment methods (ApplePay etc) * @param user Essential user details * @param paymentDetails The details of the new payment method. */ public createPaymentMethod(user: PaymentUserDetails, paymentDetails: PaypalPaymentDetails): Promise<{ paymentMethodId: string }> { const url = `${this.membershipApi}/payment-methods`; const body: any = { paymentMethod: { user: { userId: user.id, email: user.email, countryCode: user.countryCode }, deliveryAddress: { countryCode: user.countryCode }, paymentDetails: { ...paymentDetails, paymentType: 'PAYPAL' } } }; return this.requestPost({ url, key: this.subscriptionApiKey, body }); } }