diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 2c4afb5d6df..87746290ce7 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -238,6 +238,12 @@ const conf = convict({ env: 'AMPLITUDE_SCHEMA_VALIDATION', format: Boolean, }, + rawEvents: { + default: false, + doc: 'Log raw Amplitude events', + env: 'AMPLITUDE_RAW_EVENTS', + format: Boolean, + }, }, memcached: { address: { diff --git a/packages/fxa-auth-server/lib/metrics/amplitude.js b/packages/fxa-auth-server/lib/metrics/amplitude.js index 1cfe70ebded..61f95738c0e 100644 --- a/packages/fxa-auth-server/lib/metrics/amplitude.js +++ b/packages/fxa-auth-server/lib/metrics/amplitude.js @@ -17,7 +17,6 @@ const { StatsD } = require('hot-shots'); const { GROUPS, initialize } = require('fxa-shared/metrics/amplitude'); const { version: VERSION } = require('../../package.json'); -const { filterDntValues } = require('fxa-shared/metrics/dnt'); // Maps template name to email type const EMAIL_TYPES = { @@ -257,6 +256,56 @@ module.exports = (log, config) => { time: metricsContext.time || Date.now(), }; + if (config.amplitude.rawEvents) { + const wanted = [ + 'entrypoint_experiment', + 'entrypoint_variation', + 'entrypoint', + 'experiments', + 'location', + 'newsletters', + 'syncEngines', + 'templateVersion', + 'userPreferences', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', + 'utm_term', + ]; + const picked = wanted.reduce((acc, v) => { + if (data[v] !== undefined) { + acc[v] = data[v]; + } + return acc; + }, {}); + const { location } = request.app.geo; + const rawEvent = { + event, + context: { + ...picked, + eventSource: 'auth', + version: VERSION, + deviceId, + devices, + emailDomain: data.email_domain, + emailTypes: EMAIL_TYPES, + flowBeginTime, + flowId, + formFactor, + lang: request.app.locale, + location, + planId, + productId, + service, + uid, + userAgent: request.headers?.['user-agent'], + }, + }; + log.info('rawAmplitudeData', rawEvent); + statsd.increment('amplitude.event.raw'); + } + statsd.increment('amplitude.event'); const amplitudeEvent = transformEvent(event, { @@ -280,17 +329,6 @@ module.exports = (log, config) => { }); if (amplitudeEvent) { - const dnt = request && request.headers && request.headers.dnt === '1'; - - if (dnt) { - amplitudeEvent.event_properties = filterDntValues( - amplitudeEvent.event_properties - ); - amplitudeEvent.user_properties = filterDntValues( - amplitudeEvent.user_properties - ); - } - log.amplitudeEvent(amplitudeEvent); // HACK: Account reset returns a session token so emit login complete too diff --git a/packages/fxa-auth-server/lib/metrics/context.js b/packages/fxa-auth-server/lib/metrics/context.js index 417cf112270..8c758af07c1 100644 --- a/packages/fxa-auth-server/lib/metrics/context.js +++ b/packages/fxa-auth-server/lib/metrics/context.js @@ -6,7 +6,6 @@ const bufferEqualConstantTime = require('buffer-equal-constant-time'); const crypto = require('crypto'); -const { filterDntValues } = require('fxa-shared/metrics/dnt'); const HEX_STRING = require('../routes/validators').HEX_STRING; const isA = require('joi'); @@ -163,25 +162,24 @@ module.exports = function (log, config) { data.flowBeginTime = metadata.flowBeginTime; data.flowCompleteSignal = metadata.flowCompleteSignal; data.flowType = metadata.flowType; - data.entrypoint = metadata.entrypoint; - data.entrypoint_experiment = metadata.entrypointExperiment; - data.entrypoint_variation = metadata.entrypointVariation; - data.utm_campaign = metadata.utmCampaign; - data.utm_content = metadata.utmContent; - data.utm_medium = metadata.utmMedium; - data.utm_source = metadata.utmSource; - data.utm_term = metadata.utmTerm; - data.product_id = metadata.productId; - data.plan_id = metadata.planId; if (metadata.service) { data.service = metadata.service; } - } - const doNotTrack = this.headers && this.headers.dnt === '1'; - if (doNotTrack) { - data = filterDntValues(data); + const doNotTrack = this.headers && this.headers.dnt === '1'; + if (!doNotTrack) { + data.entrypoint = metadata.entrypoint; + data.entrypoint_experiment = metadata.entrypointExperiment; + data.entrypoint_variation = metadata.entrypointVariation; + data.utm_campaign = metadata.utmCampaign; + data.utm_content = metadata.utmContent; + data.utm_medium = metadata.utmMedium; + data.utm_source = metadata.utmSource; + data.utm_term = metadata.utmTerm; + data.product_id = metadata.productId; + data.plan_id = metadata.planId; + } } return data; diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 4e8364549c7..e895ebf9dec 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -2809,6 +2809,33 @@ export class StripeHelper extends StripeHelperBase { const productPaymentCycleNew = this.stripePlanToPaymentCycle(planNew); + // During upgrades it's possible that an invoice isn't created when the + // subscription is updated. Instead there will be pending invoice items + // which will be added to next invoice once its generated. + // For more info see https://stripe.com/docs/api/subscriptions/update + let upcomingInvoiceWithInvoiceitem: Stripe.Invoice | undefined; + try { + const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({ + customer: customer.id, + subscription: subscription.id, + }); + // Only use upcomingInvoice if there are `invoiceitems` + upcomingInvoiceWithInvoiceitem = upcomingInvoice?.lines.data.some( + (line) => line.type === 'invoiceitem' + ) + ? upcomingInvoice + : undefined; + } catch (error) { + if ( + error.type === 'StripeInvalidRequestError' && + error.code === 'invoice_upcoming_none' + ) { + upcomingInvoiceWithInvoiceitem = undefined; + } else { + throw error; + } + } + const baseDetails = { uid, email, @@ -2838,7 +2865,8 @@ export class StripeHelper extends StripeHelperBase { return this.extractSubscriptionUpdateCancellationDetailsForEmail( subscription, baseDetails, - invoice + invoice, + upcomingInvoiceWithInvoiceitem ); } else if (cancelAtPeriodEndOld && !cancelAtPeriodEndNew && !planOld) { return this.extractSubscriptionUpdateReactivationDetailsForEmail( @@ -2850,6 +2878,7 @@ export class StripeHelper extends StripeHelperBase { subscription, baseDetails, invoice, + upcomingInvoiceWithInvoiceitem, productOrderNew, planOld ); @@ -2866,7 +2895,8 @@ export class StripeHelper extends StripeHelperBase { async extractSubscriptionUpdateCancellationDetailsForEmail( subscription: Stripe.Subscription, baseDetails: any, - invoice: Stripe.Invoice + invoice: Stripe.Invoice, + upcomingInvoiceWithInvoiceitem: Stripe.Invoice | undefined ) { const { current_period_end: serviceLastActiveDate } = subscription; @@ -2885,7 +2915,7 @@ export class StripeHelper extends StripeHelperBase { total: invoiceTotalInCents, currency: invoiceTotalCurrency, created: invoiceDate, - } = invoice; + } = upcomingInvoiceWithInvoiceitem || invoice; return { updateType: SUBSCRIPTION_UPDATE_TYPES.CANCELLATION, @@ -2899,6 +2929,7 @@ export class StripeHelper extends StripeHelperBase { invoiceTotalInCents, invoiceTotalCurrency, serviceLastActiveDate: new Date(serviceLastActiveDate * 1000), + showOutstandingBalance: !!upcomingInvoiceWithInvoiceitem, productMetadata, planConfig, }; @@ -3021,6 +3052,7 @@ export class StripeHelper extends StripeHelperBase { subscription: Stripe.Subscription, baseDetails: any, invoice: Stripe.Invoice, + upcomingInvoiceWithInvoiceitem: Stripe.Invoice | undefined, productOrderNew: string, planOld: Stripe.Plan ) { @@ -3028,9 +3060,23 @@ export class StripeHelper extends StripeHelperBase { id: invoiceId, number: invoiceNumber, currency: paymentProratedCurrency, - amount_due: paymentProratedInCents, } = invoice; + // Using stripes default proration behaviour + // (https://stripe.com/docs/billing/subscriptions/upgrade-downgrade#immediate-payment) + // an invoice is only created at time of upgrade, if the billing period changes. + // In the case that the billing period is the same, we sum the pending invoiceItems + // retrieved in upcomingInvoiceWithInvoiceitem. + // The actual recuring charge, for the next billing cycle, shows up as a type = 'subscription' + // line item. + const onetimePaymentAmount = upcomingInvoiceWithInvoiceitem + ? upcomingInvoiceWithInvoiceitem.lines.data.reduce( + (acc, line) => + line.type === 'invoiceitem' ? acc + line.amount : acc, + 0 + ) + : invoice.amount_due; + // https://github.com/mozilla/subhub/blob/e224feddcdcbafaf0f3cd7d52691d29d94157de5/src/hub/vendor/customer.py#L643 const abbrevProductOld = await this.expandAbbrevProductForPlan(planOld); const { @@ -3062,7 +3108,7 @@ export class StripeHelper extends StripeHelperBase { paymentAmountOldCurrency, invoiceNumber, invoiceId, - paymentProratedInCents, + paymentProratedInCents: onetimePaymentAmount, paymentProratedCurrency, }; } diff --git a/packages/fxa-auth-server/lib/senders/email.js b/packages/fxa-auth-server/lib/senders/email.js index a6b246b0f72..8461bd25644 100644 --- a/packages/fxa-auth-server/lib/senders/email.js +++ b/packages/fxa-auth-server/lib/senders/email.js @@ -2096,6 +2096,7 @@ module.exports = function (log, config, bounces) { invoiceTotalInCents, invoiceTotalCurrency, serviceLastActiveDate, + showOutstandingBalance, } = message; const enabled = config.subscriptions.transactionalEmails.enabled; @@ -2142,6 +2143,7 @@ module.exports = function (log, config, bounces) { invoiceTotalCurrency, message.acceptLanguage ), + showOutstandingBalance, }, }); }; diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/en.ftl b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/en.ftl index 69ad7c991ca..491ea9d4d3c 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/en.ftl +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/en.ftl @@ -8,3 +8,4 @@ subscriptionCancellation-title = Sorry to see you go # $invoiceDateOnly (String) - The date of the invoice, e.g. 01/20/2016 # $serviceLastActiveDateOnly (String) - The date of last active service, e.g. 01/20/2016 subscriptionCancellation-content = We’ve cancelled your { $productName } subscription. Your final payment of { $invoiceTotal } was paid on { $invoiceDateOnly }. Your service will continue until the end of your current billing period, which is { $serviceLastActiveDateOnly }. +subscriptionCancellation-outstanding-content = We’ve cancelled your { $productName } subscription. Your final payment of { $invoiceTotal } will be paid on { $invoiceDateOnly }. Your service will continue until the end of your current billing period, which is { $serviceLastActiveDateOnly }. diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.mjml b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.mjml index 09d3f9876b4..58f13a6610e 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.mjml +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.mjml @@ -13,9 +13,15 @@ - + <% if (!showOutstandingBalance) { %> + We’ve cancelled your <%- productName %> subscription. Your final payment of <%- invoiceTotal %> was paid on <%- invoiceDateOnly %>. Your service will continue until the end of your current billing period, which is <%- serviceLastActiveDateOnly %>. + <% } else { %> + + We’ve cancelled your <%- productName %> subscription. Your final payment of <%- invoiceTotal %> will be paid on <%- invoiceDateOnly %>. Your service will continue until the end of your current billing period, which is <%- serviceLastActiveDateOnly %>. + + <% } %> diff --git a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.txt b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.txt index 62541ec0c6c..276a9cee1c5 100644 --- a/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.txt +++ b/packages/fxa-auth-server/lib/senders/emails/templates/subscriptionCancellation/index.txt @@ -2,6 +2,10 @@ subscriptionCancellation-subject = "Your <%- productName %> subscription has bee subscriptionCancellation-title = "Sorry to see you go" +<% if (!showOutstandingBalance) { %> subscriptionCancellation-content = "We’ve cancelled your <%- productName %> subscription. Your final payment of <%- invoiceTotal %> was paid on <%- invoiceDateOnly %>. Your service will continue until the end of your current billing period, which is <%- serviceLastActiveDateOnly %>." +<% } else { %> +subscriptionCancellation-outstanding-content = "We’ve cancelled your <%- productName %> subscription. Your final payment of <%- invoiceTotal %> will be paid on <%- invoiceDateOnly %>. Your service will continue until the end of your current billing period, which is <%- serviceLastActiveDateOnly %>." +<% } %> <%- include ('/partials/cancellationSurvey/index.txt') %> diff --git a/packages/fxa-auth-server/test/local/metrics/amplitude.js b/packages/fxa-auth-server/test/local/metrics/amplitude.js index 0ec1d099f00..a9f784b38f3 100644 --- a/packages/fxa-auth-server/test/local/metrics/amplitude.js +++ b/packages/fxa-auth-server/test/local/metrics/amplitude.js @@ -5,6 +5,7 @@ 'use strict'; const { assert } = require('chai'); +const { version } = require('../../../package.json'); const { StatsD } = require('hot-shots'); const { Container } = require('typedi'); const sinon = require('sinon'); @@ -12,20 +13,17 @@ const metricsEnabled = sinon.stub(); metricsEnabled.withArgs('frip').resolves(false); metricsEnabled.withArgs('blee').resolves(true); const proxyquire = require('proxyquire'); -const dntStub = sinon.stub(); const amplitudeModule = proxyquire('../../../lib/metrics/amplitude', { 'fxa-shared/db/models/auth': { Account: { metricsEnabled, }, }, - 'fxa-shared/metrics/dnt': { - filterDntValues: dntStub, - }, }); const mocks = require('../../mocks'); const mockAmplitudeConfig = { schemaValidation: true, + rawEvents: false, }; const DAY = 1000 * 60 * 60 * 24; @@ -38,6 +36,7 @@ describe('metrics/amplitude', () => { beforeEach(() => { log = mocks.mockLog(); + mockAmplitudeConfig.rawEvents = false; amplitude = amplitudeModule(log, { amplitude: mockAmplitudeConfig, oauth: { @@ -101,6 +100,87 @@ describe('metrics/amplitude', () => { }); }); + describe('raw events enabled', () => { + it('logged a raw event', async () => { + const statsd = { increment: sinon.spy() }; + Container.set(StatsD, statsd); + mockAmplitudeConfig.rawEvents = true; + const now = Date.now(); + await amplitude( + 'account.confirmed', + mocks.mockRequest({ + uaBrowser: 'foo', + uaBrowserVersion: 'bar', + uaOS: 'baz', + uaOSVersion: 'qux', + uaDeviceType: 'pawk', + uaFormFactor: 'melm', + locale: 'wibble', + credentials: { + uid: 'blee', + }, + devices: [], + geo: { + location: { + country: 'United Kingdom', + state: 'England', + }, + }, + query: { + service: '0', + }, + payload: { + metricsContext: { + deviceId: 'juff', + flowId: 'udge', + flowBeginTime: 'kwop', + }, + }, + }), + { useless: 'junk', utm_source: 'quuz' }, + { time: now } + ); + const expectedEvent = { + time: now, + type: 'account.confirmed', + }; + const expectedContext = { + deviceId: 'juff', + devices: [], + emailDomain: undefined, + emailTypes: amplitudeModule.EMAIL_TYPES, + eventSource: 'auth', + flowBeginTime: 'kwop', + flowId: 'udge', + formFactor: 'melm', + lang: 'wibble', + location: { + country: 'United Kingdom', + state: 'England', + }, + planId: undefined, + productId: undefined, + service: '0', + uid: 'blee', + userAgent: 'test user-agent', + utm_source: 'quuz', + version, + }; + assert.deepEqual(log.info.args[0][1]['event'], expectedEvent); + assert.deepEqual(log.info.args[0][1]['context'], expectedContext); + assert.isTrue(log.info.calledOnceWith('rawAmplitudeData'), { + event: expectedEvent, + context: expectedContext, + }); + sinon.assert.calledTwice(statsd.increment); + sinon.assert.calledWith( + statsd.increment.firstCall, + 'amplitude.event.raw' + ); + sinon.assert.calledWith(statsd.increment.secondCall, 'amplitude.event'); + }); + }); + describe('sets metricsUid when uid specified', () => { it('credentials with metricsOptOutAt set do not log', async () => { const request = mocks.mockRequest({ @@ -842,25 +922,5 @@ describe('metrics/amplitude', () => { assert.equal(args[0].event_properties.product_id, 'foo'); }); }); - - describe('when Do Not Track is set', () => { - it('calls the filtering function', async () => { - await amplitude( - 'account.created', - mocks.mockRequest({ - payload: { - metricsContext: { - planId: 'bar', - productId: 'foo', - }, - }, - headers: { dnt: '1' }, - }), - {}, - {} - ); - sinon.assert.calledTwice(dntStub); - }); - }); }); }); diff --git a/packages/fxa-auth-server/test/local/metrics/events.js b/packages/fxa-auth-server/test/local/metrics/events.js index a6f48913a00..541fc135635 100644 --- a/packages/fxa-auth-server/test/local/metrics/events.js +++ b/packages/fxa-auth-server/test/local/metrics/events.js @@ -25,6 +25,7 @@ const amplitudeModule = proxyquire('../../../lib/metrics/amplitude', { const events = proxyquire('../../../lib/metrics/events', { './amplitude': amplitudeModule, })(log, { + amplitude: { rawEvents: false }, oauth: { clientIds: {}, }, diff --git a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js index e1095a93d12..e3b2bc5c0f5 100644 --- a/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js +++ b/packages/fxa-auth-server/test/local/payments/iap/apple-app-store/subscription-purchase.js @@ -36,6 +36,7 @@ describe('SubscriptionPurchase', () => { const inAppOwnershipType = 'PURCHASED'; const originalPurchaseDate = 1627306493000; const autoRenewProductId = productId; + const purchaseDate = 1649329745000; const apiResponse = deepCopy(appStoreApiResponse); const decodedTransactionInfo = deepCopy(transactionInfo); const decodedRenewalInfo = deepCopy(renewalInfo); @@ -81,6 +82,7 @@ describe('SubscriptionPurchase', () => { inAppOwnershipType, originalPurchaseDate, autoRenewProductId, + purchaseDate, }; assert.deepEqual(expected, subscription); }); diff --git a/packages/fxa-auth-server/test/local/payments/stripe.js b/packages/fxa-auth-server/test/local/payments/stripe.js index 5abb821157e..b478e1ff576 100644 --- a/packages/fxa-auth-server/test/local/payments/stripe.js +++ b/packages/fxa-auth-server/test/local/payments/stripe.js @@ -5595,7 +5595,58 @@ describe('StripeHelper', () => { } } + it('calls the expected helper method for cancellation, with retrieveUpcoming error', async () => { + const error = new Error('Stripe error'); + error.type = 'StripeInvalidRequestError'; + error.code = 'invoice_upcoming_none'; + mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(error); + const event = deepCopy(eventCustomerSubscriptionUpdated); + event.data.object.cancel_at_period_end = true; + event.data.previous_attributes = { cancel_at_period_end: false }; + const result = + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( + event + ); + assert.equal(result, mockCancellationDetails); + assertOnlyExpectedHelperCalledWith( + 'extractSubscriptionUpdateCancellationDetailsForEmail', + event.data.object, + expectedBaseUpdateDetails, + mockInvoice, + undefined + ); + }); + + it('rejects if invoices.retrieveUpcoming errors with unexpected error', async () => { + const error = new Error('Stripe error'); + error.type = 'unexpected'; + mockStripe.invoices.retrieveUpcoming = sinon.stub().rejects(error); + const event = deepCopy(eventCustomerSubscriptionUpdated); + event.data.object.cancel_at_period_end = true; + event.data.previous_attributes = { cancel_at_period_end: false }; + try { + await stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( + event + ); + } catch (err) { + assert.equal(err.type, 'unexpected'); + } + assert.isTrue( + stripeHelper['extractSubscriptionUpdateCancellationDetailsForEmail'] + .notCalled + ); + }); + it('calls the expected helper method for cancellation', async () => { + const mockInvoiceUpcomingWithData = { + ...mockInvoiceUpcoming, + lines: { + data: [{ type: 'invoiceitem' }], + }, + }; + mockStripe.invoices.retrieveUpcoming = sinon + .stub() + .resolves(mockInvoiceUpcomingWithData); const event = deepCopy(eventCustomerSubscriptionUpdated); event.data.object.cancel_at_period_end = true; event.data.previous_attributes = { cancel_at_period_end: false }; @@ -5608,7 +5659,8 @@ describe('StripeHelper', () => { 'extractSubscriptionUpdateCancellationDetailsForEmail', event.data.object, expectedBaseUpdateDetails, - mockInvoice + mockInvoice, + mockInvoiceUpcomingWithData ); }); @@ -5646,6 +5698,7 @@ describe('StripeHelper', () => { event.data.object, expectedBaseUpdateDetails, mockInvoice, + undefined, event.data.object.plan.metadata.productOrder, oldPlan ); @@ -5669,6 +5722,7 @@ describe('StripeHelper', () => { event.data.object, expectedBaseUpdateDetails, mockInvoice, + undefined, event.data.object.plan.metadata.productOrder, oldPlan ); @@ -5691,7 +5745,8 @@ describe('StripeHelper', () => { 'extractSubscriptionUpdateCancellationDetailsForEmail', event.data.object, { ...expectedBaseUpdateDetails, planConfig: mockPlanConfig }, - mockInvoice + mockInvoice, + undefined ); }); }); @@ -5704,88 +5759,100 @@ describe('StripeHelper', () => { const productDownloadURLNew = 'http://example.com/download-new'; describe('extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail', () => { - const commonTest = (isUpgrade) => async () => { - const event = deepCopy(eventCustomerSubscriptionUpdated); - const productIdOld = event.data.previous_attributes.plan.product; - const productIdNew = event.data.object.plan.product; - - const baseDetails = { - ...expectedBaseUpdateDetails, - productIdNew, - productNameNew, - productIconURLNew, - productMetadata: { - ...expectedBaseUpdateDetails.productMetadata, - emailIconURL: productIconURLNew, - successActionButtonURL: productDownloadURLNew, - }, - }; - - mockAllAbbrevProducts.push( - { - product_id: productIdOld, - product_name: productNameOld, - product_metadata: { - ...mockProduct.metadata, - emailIconUrl: productIconURLOld, - successActionButtonURL: productDownloadURLOld, - }, - }, - { - product_id: productIdNew, - product_name: productNameNew, - product_metadata: { - ...mockProduct.metadata, - emailIconUrl: productIconURLNew, + const commonTest = + ( + isUpgrade, + upcomingInvoice = undefined, + expectedPaymentProratedInCents + ) => + async () => { + const event = deepCopy(eventCustomerSubscriptionUpdated); + const productIdOld = event.data.previous_attributes.plan.product; + const productIdNew = event.data.object.plan.product; + + const baseDetails = { + ...expectedBaseUpdateDetails, + productIdNew, + productNameNew, + productIconURLNew, + productMetadata: { + ...expectedBaseUpdateDetails.productMetadata, + emailIconURL: productIconURLNew, successActionButtonURL: productDownloadURLNew, }, - } - ); - mockAllAbbrevPlans.unshift( - { - ...event.data.previous_attributes.plan, - plan_id: event.data.previous_attributes.plan.id, - product_id: productIdOld, - }, - { - ...event.data.object.plan, - plan_id: event.data.object.plan.id, - product_id: productIdNew, - } - ); - - event.data.object.plan.metadata.productOrder = isUpgrade ? 2 : 1; - event.data.previous_attributes.plan.metadata.productOrder = isUpgrade - ? 1 - : 2; + }; - const result = - await stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail( - event.data.object, - baseDetails, - mockInvoice, - event.data.object.plan.metadata.productOrder, - event.data.previous_attributes.plan + mockAllAbbrevProducts.push( + { + product_id: productIdOld, + product_name: productNameOld, + product_metadata: { + ...mockProduct.metadata, + emailIconUrl: productIconURLOld, + successActionButtonURL: productDownloadURLOld, + }, + }, + { + product_id: productIdNew, + product_name: productNameNew, + product_metadata: { + ...mockProduct.metadata, + emailIconUrl: productIconURLNew, + successActionButtonURL: productDownloadURLNew, + }, + } + ); + mockAllAbbrevPlans.unshift( + { + ...event.data.previous_attributes.plan, + plan_id: event.data.previous_attributes.plan.id, + product_id: productIdOld, + }, + { + ...event.data.object.plan, + plan_id: event.data.object.plan.id, + product_id: productIdNew, + } ); - assert.deepEqual(result, { - ...baseDetails, - productIdNew, - updateType: - SUBSCRIPTION_UPDATE_TYPES[isUpgrade ? 'UPGRADE' : 'DOWNGRADE'], - productIdOld, - productNameOld, - productIconURLOld, - productPaymentCycleOld: event.data.previous_attributes.plan.interval, - paymentAmountOldCurrency: - event.data.previous_attributes.plan.currency, - paymentAmountOldInCents: event.data.previous_attributes.plan.amount, - paymentProratedCurrency: mockInvoice.currency, - paymentProratedInCents: mockInvoice.amount_due, - invoiceNumber: mockInvoice.number, - invoiceId: mockInvoice.id, - }); - }; + event.data.object.plan.metadata.productOrder = isUpgrade ? 2 : 1; + event.data.previous_attributes.plan.metadata.productOrder = isUpgrade + ? 1 + : 2; + + const result = + await stripeHelper.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail( + event.data.object, + baseDetails, + mockInvoice, + upcomingInvoice, + event.data.object.plan.metadata.productOrder, + event.data.previous_attributes.plan + ); + + assert.deepEqual(result, { + ...baseDetails, + productIdNew, + updateType: + SUBSCRIPTION_UPDATE_TYPES[isUpgrade ? 'UPGRADE' : 'DOWNGRADE'], + productIdOld, + productNameOld, + productIconURLOld, + productPaymentCycleOld: + event.data.previous_attributes.plan.interval, + paymentAmountOldCurrency: + event.data.previous_attributes.plan.currency, + paymentAmountOldInCents: event.data.previous_attributes.plan.amount, + paymentProratedCurrency: upcomingInvoice + ? upcomingInvoice.currency + : mockInvoice.currency, + paymentProratedInCents: upcomingInvoice + ? expectedPaymentProratedInCents + : mockInvoice.amount_due, + invoiceNumber: mockInvoice.number, + invoiceId: mockInvoice.id, + }); + }; it( 'extracts expected details for a subscription upgrade', @@ -5857,6 +5924,7 @@ describe('StripeHelper', () => { event.data.object, baseDetails, mockInvoice, + undefined, event.data.object.plan.metadata.productOrder, event.data.previous_attributes.plan ); @@ -5866,6 +5934,23 @@ describe('StripeHelper', () => { result.productPaymentCycleNew ); }); + + it( + 'extracts expected details for a subscription upgrade with pending invoice items', + commonTest( + false, + { + currency: 'usd', + lines: { + data: [ + { type: 'invoiceitem', amount: -500 }, + { type: 'invoiceitem', amount: 2500 }, + ], + }, + }, + 2000 + ) + ); }); describe('extractSubscriptionUpdateReactivationDetailsForEmail', () => { @@ -6100,7 +6185,8 @@ describe('StripeHelper', () => { await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail( event.data.object, expectedBaseUpdateDetails, - mockInvoice + mockInvoice, + undefined ); const subscription = event.data.object; assert.deepEqual(result, { @@ -6119,6 +6205,42 @@ describe('StripeHelper', () => { subscription.current_period_end * 1000 ), productMetadata: expectedBaseUpdateDetails.productMetadata, + showOutstandingBalance: false, + }); + }); + + it('extracts expected details for a subscription cancellation with pending invoice items', async () => { + const mockUpcomingInvoice = { + total: '40839', + currency: 'usd', + created: 1666968725952, + }; + const event = deepCopy(eventCustomerSubscriptionUpdated); + const result = + await stripeHelper.extractSubscriptionUpdateCancellationDetailsForEmail( + event.data.object, + expectedBaseUpdateDetails, + mockInvoice, + mockUpcomingInvoice + ); + const subscription = event.data.object; + assert.deepEqual(result, { + updateType: SUBSCRIPTION_UPDATE_TYPES.CANCELLATION, + email, + uid, + productId, + planId, + planConfig: {}, + planEmailIconURL: productIconURLNew, + productName, + invoiceDate: new Date(mockUpcomingInvoice.created * 1000), + invoiceTotalInCents: mockUpcomingInvoice.total, + invoiceTotalCurrency: mockUpcomingInvoice.currency, + serviceLastActiveDate: new Date( + subscription.current_period_end * 1000 + ), + productMetadata: expectedBaseUpdateDetails.productMetadata, + showOutstandingBalance: true, }); }); }); diff --git a/packages/fxa-auth-server/test/local/senders/emails.ts b/packages/fxa-auth-server/test/local/senders/emails.ts index 302157c12b3..db01cc98b12 100644 --- a/packages/fxa-auth-server/test/local/senders/emails.ts +++ b/packages/fxa-auth-server/test/local/senders/emails.ts @@ -1497,6 +1497,40 @@ const TESTS: [string, any, Record?][] = [ { test: 'notInclude', expected: 'utm_source=email' }, ]] ])], + + ['subscriptionCancellationEmail', new Map([ + ['subject', { test: 'equal', expected: `Your ${MESSAGE.productName} subscription has been cancelled` }], + ['headers', new Map([ + ['X-SES-MESSAGE-TAGS', { test: 'equal', expected: sesMessageTagsHeaderValue('subscriptionCancellation') }], + ['X-Template-Name', { test: 'equal', expected: 'subscriptionCancellation' }], + ['X-Template-Version', { test: 'equal', expected: TEMPLATE_VERSIONS.subscriptionCancellation }], + ])], + ['html', [ + { test: 'include', expected: `Your ${MESSAGE.productName} subscription has been cancelled` }, + { test: 'include', expected: 'Sorry to see you go' }, + { test: 'include', expected: decodeUrl(configHref('subscriptionSettingsUrl', 'subscription-cancellation', 'reactivate-subscription', 'plan_id', 'product_id', 'uid', 'email')) }, + { test: 'include', expected: decodeUrl(configHref('subscriptionTermsUrl', 'subscription-cancellation', 'subscription-terms')) }, + { test: 'include', expected: SUBSCRIPTION_CANCELLATION_SURVEY_URL }, + { test: 'include', expected: `cancelled your ${MESSAGE.productName} subscription` }, + { test: 'include', expected: `final payment of ${MESSAGE_FORMATTED.invoiceTotal} will be paid on 03/20/2020.` }, + { test: 'include', expected: `billing period, which is 04/19/2020.` }, + { test: 'notInclude', expected: `alt="${MESSAGE.productName}"`}, + { test: 'notInclude', expected: 'utm_source=email' }, + ]], + ['text', [ + { test: 'include', expected: `Your ${MESSAGE.productName} subscription has been cancelled` }, + { test: 'include', expected: 'Sorry to see you go' }, + { test: 'include', expected: `cancelled your ${MESSAGE.productName} subscription` }, + { test: 'include', expected: `final payment of ${MESSAGE_FORMATTED.invoiceTotal} will be paid on 03/20/2020.` }, + { test: 'include', expected: `billing period, which is 04/19/2020.` }, + { test: 'include', expected: SUBSCRIPTION_CANCELLATION_SURVEY_URL }, + { test: 'notInclude', expected: 'utm_source=email' }, + ]] + ]), + {updateTemplateValues: x => ( + {...x, showOutstandingBalance: true})} + ], + ['subscriptionCancellationEmail', new Map([ ['html', [ { test: 'include', expected: SUBSCRIPTION_CANCELLATION_SURVEY_URL_CUSTOM }, diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index 0d030d1dd92..fb061f9f217 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -715,6 +715,7 @@ function mockRequest(data, errors) { const events = proxyquire('../lib/metrics/events', { './amplitude': amplitudeModule, })(data.log || module.exports.mockLog(), { + amplitude: { rawEvents: false }, oauth: { clientIds: data.clientIds || {}, }, diff --git a/packages/fxa-content-server/server/lib/amplitude.js b/packages/fxa-content-server/server/lib/amplitude.js index e9f7c2e2ee6..80ba6c86e04 100644 --- a/packages/fxa-content-server/server/lib/amplitude.js +++ b/packages/fxa-content-server/server/lib/amplitude.js @@ -14,6 +14,7 @@ 'use strict'; +const _ = require('lodash'); const { GROUPS, initialize, @@ -23,7 +24,6 @@ const { mapOs, validate, } = require('fxa-shared/metrics/amplitude'); -const { filterDntValues } = require('fxa-shared/metrics/dnt'); const logger = require('./logging/log')(); const ua = require('fxa-shared/metrics/user-agent'); const config = require('./configuration'); @@ -685,6 +685,46 @@ function receiveEvent(event, request, data) { return; } + if (amplitude.rawEvents) { + const rawEvent = { + event, + context: { + eventSource: 'content', + version: VERSION, + emailTypes: EMAIL_TYPES, + userAgent: request.headers && request.headers['user-agent'], + ..._.pick(data, [ + 'deviceId', + 'devices', + 'emailDomain', + 'entrypoint_experiment', + 'entrypoint_variation', + 'entrypoint', + 'experiments', + 'flowBeginTime', + 'flowId', + 'lang', + 'location', + 'newsletters', + 'planId', + 'productId', + 'service', + 'syncEngines', + 'templateVersion', + 'uid', + 'userPreferences', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', + 'utm_term', + ]), + }, + }; + logger.info('rawAmplitudeData', rawEvent); + statsd.increment('amplitude.event.raw'); + } + const userAgent = ua.parse(request.headers && request.headers['user-agent']); const amplitudeEvent = transform( @@ -726,17 +766,6 @@ function receiveEvent(event, request, data) { } } - const dnt = request && request.headers && request.headers.dnt === '1'; - - if (dnt) { - amplitudeEvent.event_properties = filterDntValues( - amplitudeEvent.event_properties - ); - amplitudeEvent.user_properties = filterDntValues( - amplitudeEvent.user_properties - ); - } - logger.info('amplitudeEvent', amplitudeEvent); } else { statsd.increment('amplitude.event.dropped'); diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index e6b54de226b..bda19694171 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -52,6 +52,12 @@ const conf = (module.exports = convict({ env: 'AMPLITUDE_SCHEMA_VALIDATION', format: Boolean, }, + rawEvents: { + default: false, + doc: 'Log raw Amplitude events', + env: 'AMPLITUDE_RAW_EVENTS', + format: Boolean, + }, }, are_dist_resources: { default: false, diff --git a/packages/fxa-content-server/server/lib/flow-event.js b/packages/fxa-content-server/server/lib/flow-event.js index e94ceb6a223..572c89e4296 100644 --- a/packages/fxa-content-server/server/lib/flow-event.js +++ b/packages/fxa-content-server/server/lib/flow-event.js @@ -18,15 +18,21 @@ const geolocate = require('fxa-shared/express/geo-locate')(geodb)( )(log); const os = require('os'); const statsd = require('./statsd'); -const { filterDntValues } = require('fxa-shared/metrics/dnt'); const { VERSION, PERFORMANCE_TIMINGS, limitLength, isValidTime, -} = require('fxa-shared/metrics/flow-performance'); +} = require('fxa-shared').metrics.flowPerformance; const VALIDATION_PATTERNS = require('./validation').PATTERNS; +const DNT_ALLOWED_DATA = ['context', 'entrypoint', 'service']; +const NO_DNT_ALLOWED_DATA = DNT_ALLOWED_DATA.concat([ + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_source', +]); const HOSTNAME = os.hostname(); const FLOW_BEGIN_EVENT = 'flow.begin'; @@ -194,11 +200,9 @@ function logFlowEvent(event, data, request) { optionallySetFallbackData(eventData, 'service', data.client_id); optionallySetFallbackData(eventData, 'entrypoint', data.entryPoint); - const filteredData = isDNT(request) ? filterDntValues(eventData) : eventData; - // The data pipeline listens on stderr. - process.stderr.write(JSON.stringify(filteredData) + '\n'); - logStatsdPerfEvent(filteredData); + process.stderr.write(JSON.stringify(eventData) + '\n'); + logStatsdPerfEvent(eventData); } function logStatsdPerfEvent(eventData) { @@ -218,16 +222,11 @@ function logStatsdPerfEvent(eventData) { } function pickFlowData(data, request) { - const keys = [ - 'context', - 'entrypoint', - 'service', - 'utm_campaign', - 'utm_content', - 'utm_medium', - 'utm_source', - ]; - const pickedData = _.pick(data, keys); + if (isDNT(request)) { + return _.pick(data, DNT_ALLOWED_DATA); + } + + const pickedData = _.pick(data, NO_DNT_ALLOWED_DATA); return _.pickBy(pickedData, (value, key) => { if (key.indexOf('utm_') === 0) { diff --git a/packages/fxa-content-server/tests/server/amplitude.js b/packages/fxa-content-server/tests/server/amplitude.js index 33b787bad87..5f0e419ad71 100644 --- a/packages/fxa-content-server/tests/server/amplitude.js +++ b/packages/fxa-content-server/tests/server/amplitude.js @@ -16,9 +16,9 @@ const statsd = { }; const amplitudeConfig = { disabled: false, + rawEvents: false, schemaValidation: false, }; -const dntStub = sinon.stub(); const amplitude = proxyquire(path.resolve('server/lib/amplitude'), { './configuration': { @@ -36,7 +36,6 @@ const amplitude = proxyquire(path.resolve('server/lib/amplitude'), { }, './logging/log': () => logger, './statsd': statsd, - 'fxa-shared/metrics/dnt': { filterDntValues: dntStub }, }); const APP_VERSION_RE = /([0-9]+)\.([0-9]{1,2})$/; @@ -71,6 +70,7 @@ function createAmplitudeEvent( registerSuite('amplitude', { beforeEach: function () { amplitudeConfig.disabled = false; + amplitudeConfig.rawEvents = false; sinon.stub(process.stderr, 'write').callsFake(() => {}); }, @@ -94,6 +94,7 @@ registerSuite('amplitude', { 'disable writing amplitude events': { 'logger.info was not called': () => { amplitudeConfig.disabled = true; + amplitudeConfig.rawEvents = true; amplitude( { time: 'a', @@ -122,6 +123,110 @@ registerSuite('amplitude', { assert.doesNotThrow(() => amplitude(null, {})); }, + 'logs raw event with context': () => { + amplitudeConfig.rawEvents = true; + const event = { + type: 'oauth.signup.success', + offset: 3846, + time: 1585695795375, + flowTime: 3847, + }; + + const context = { + eventSource: 'content', + version: pkg.version, + emailTypes: { + 'complete-reset-password': 'reset_password', + 'complete-signin': 'login', + 'verify-email': 'registration', + }, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:40.0) Gecko/20100101 Firefox/40.0 FxATester/1.0', + deviceId: '6519d5fce18a427e87745ef6c25143ba', + devices: [{ name: 'cray-1', lastAccessTime: 1585695795375 }], + emailDomain: 'other', + entrypoint_experiment: 'herf', + entrypoint_variation: 'menk', + entrypoint: 'zoo', + experiments: ['abc', 'ASAP'], + flowBeginTime: 1585695791528, + flowId: + '804e3ce43ed994db863afcb93640809c239f6db0378a6f2b01659f7e26e25a66', + lang: 'en', + location: { + country: 'United States', + state: 'California', + }, + newsletters: 'none', + planId: 'abc', + productId: 'gamma', + service: 'dcdb5ae7add825d2', + syncEngines: ['bookmarks', 'history'], + templateVersion: '3.1', + uid: '66853f3ab5404b5f30674d532a2dd54e', + userPreferences: { yes: 'no' }, + utm_campaign: 'none', + utm_content: 'none', + utm_medium: 'none', + utm_source: 'none', + utm_term: 'none', + }; + amplitude( + event, + { + headers: { + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:40.0) Gecko/20100101 Firefox/40.0 FxATester/1.0', + }, + }, + { + junk: 'dontpickthis', + deviceId: '6519d5fce18a427e87745ef6c25143ba', + devices: [{ name: 'cray-1', lastAccessTime: 1585695795375 }], + emailDomain: 'other', + entrypoint_experiment: 'herf', + entrypoint_variation: 'menk', + entrypoint: 'zoo', + experiments: ['abc', 'ASAP'], + flowBeginTime: 1585695791528, + flowId: + '804e3ce43ed994db863afcb93640809c239f6db0378a6f2b01659f7e26e25a66', + lang: 'en', + location: { + country: 'United States', + state: 'California', + }, + newsletters: 'none', + planId: 'abc', + productId: 'gamma', + service: 'dcdb5ae7add825d2', + syncEngines: ['bookmarks', 'history'], + templateVersion: '3.1', + uid: '66853f3ab5404b5f30674d532a2dd54e', + userPreferences: { yes: 'no' }, + utm_campaign: 'none', + utm_content: 'none', + utm_medium: 'none', + utm_source: 'none', + utm_term: 'none', + } + ); + + assert.isTrue( + logger.info.calledOnceWith('rawAmplitudeData', { event, context }) + ); + sinon.assert.calledThrice(statsd.increment); + sinon.assert.calledWith( + statsd.increment.firstCall, + 'amplitude.event.raw' + ); + sinon.assert.calledWith(statsd.increment.secondCall, 'amplitude.event'); + sinon.assert.calledWith( + statsd.increment.thirdCall, + 'amplitude.event.dropped' + ); + }, + 'flow.reset-password.submit': () => { amplitude( { @@ -2492,13 +2597,5 @@ registerSuite('amplitude', { 'fxa_connect_device - pair_notnow_engage' ); }, - - 'respects do not track': () => { - createAmplitudeEvent('screen.pair.notnow.engage', { - ...BASIC_REQUEST, - headers: { ...BASIC_REQUEST.headers, dnt: '1' }, - }); - sinon.assert.calledTwice(dntStub); - }, }, }); diff --git a/packages/fxa-content-server/tests/server/flow-event.js b/packages/fxa-content-server/tests/server/flow-event.js index 2dc564f2e40..56266cf3d00 100644 --- a/packages/fxa-content-server/tests/server/flow-event.js +++ b/packages/fxa-content-server/tests/server/flow-event.js @@ -936,7 +936,6 @@ registerSuite('flow-event', { 'process.stderr.write was called correctly': () => { assert.equal(process.stderr.write.callCount, 1); const arg = JSON.parse(process.stderr.write.args[0][0]); - assert.isUndefined(arg.entrypoint); assert.isUndefined(arg.utm_campaign); assert.isUndefined(arg.utm_content); assert.isUndefined(arg.utm_medium); diff --git a/packages/fxa-payments-server/server/config/index.js b/packages/fxa-payments-server/server/config/index.js index f0c5fcb40e6..7bad9b93c4b 100644 --- a/packages/fxa-payments-server/server/config/index.js +++ b/packages/fxa-payments-server/server/config/index.js @@ -38,6 +38,12 @@ const conf = convict({ env: 'AMPLITUDE_SCHEMA_VALIDATION', format: Boolean, }, + rawEvents: { + default: false, + doc: 'Log raw Amplitude events', + env: 'AMPLITUDE_RAW_EVENTS', + format: Boolean, + }, }, clientAddressDepth: { default: 3, diff --git a/packages/fxa-payments-server/server/lib/amplitude.js b/packages/fxa-payments-server/server/lib/amplitude.js index 31a398d34ef..937fb459a9d 100644 --- a/packages/fxa-payments-server/server/lib/amplitude.js +++ b/packages/fxa-payments-server/server/lib/amplitude.js @@ -12,7 +12,6 @@ const { toSnakeCase, validate, } = require('fxa-shared/metrics/amplitude'); -const { filterDntValues } = require('fxa-shared/metrics/dnt'); const config = require('../config'); const amplitude = config.get('amplitude'); const log = require('./logging/log')(); @@ -45,6 +44,53 @@ module.exports = (event, request, data) => { return; } + if (amplitude.rawEvents) { + const wanted = [ + 'deviceId', + 'devices', + 'emailDomain', + 'entrypoint_experiment', + 'entrypoint_variation', + 'entrypoint', + 'experiments', + 'flowBeginTime', + 'flowId', + 'lang', + 'location', + 'newsletters', + 'planId', + 'productId', + 'service', + 'syncEngines', + 'templateVersion', + 'uid', + 'userPreferences', + 'utm_campaign', + 'utm_content', + 'utm_medium', + 'utm_referrer', + 'utm_source', + 'utm_term', + ]; + const picked = wanted.reduce((acc, v) => { + if (data[v] !== undefined) { + acc[v] = data[v]; + } + return acc; + }, {}); + const rawEvent = { + event, + context: { + eventSource: 'payments', + version: VERSION, + userAgent: request.headers?.['user-agent'], + ...picked, + }, + }; + log.info('rawAmplitudeData', rawEvent); + statsd.increment('amplitude.event.raw'); + } + const userAgent = ua.parse(request.headers?.['user-agent']); statsd.increment('amplitude.event'); @@ -84,16 +130,6 @@ module.exports = (event, request, data) => { } } - const dnt = request.headers?.['dnt'] === '1'; - if (dnt) { - amplitudeEvent.event_properties = filterDntValues( - amplitudeEvent.event_properties - ); - amplitudeEvent.user_properties = filterDntValues( - amplitudeEvent.user_properties - ); - } - // Amplitude events are logged to stdout, where they are picked up by the // stackdriver logging agent. log.info('amplitudeEvent', amplitudeEvent); diff --git a/packages/fxa-payments-server/server/lib/amplitude.test.js b/packages/fxa-payments-server/server/lib/amplitude.test.js index b43a9c7b297..7c22c6a2092 100644 --- a/packages/fxa-payments-server/server/lib/amplitude.test.js +++ b/packages/fxa-payments-server/server/lib/amplitude.test.js @@ -2,19 +2,17 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +const pkg = require('../../package.json'); const mockSchemaValidatorFn = jest.fn(); const mockAmplitudeConfig = { enabled: true, schemaValidation: true, + rawEvents: false, }; jest.mock('fxa-shared/metrics/amplitude.js', () => ({ ...jest.requireActual('fxa-shared/metrics/amplitude.js'), validate: mockSchemaValidatorFn, })); -const mockDntFn = jest.fn(); -jest.mock('fxa-shared/metrics/dnt', () => ({ - filterDntValues: mockDntFn, -})); let scope; const mockSentry = { withScope: jest.fn().mockImplementation((cb) => { @@ -92,6 +90,7 @@ describe('lib/amplitude', () => { log.error.mockClear(); mockSchemaValidatorFn.mockReset(); mockAmplitudeConfig.schemaValidation = true; + mockAmplitudeConfig.rawEvents = false; Container.set(StatsD, { increment: jest.fn() }); }); it('logs a correctly formatted message', () => { @@ -102,6 +101,35 @@ describe('lib/amplitude', () => { expect(log.info.mock.calls[0][1]).toMatchObject(expectedOutput); expect(statsd.increment).toHaveBeenCalledTimes(1); }); + it('logs raw events', () => { + const statsd = Container.get(StatsD); + const expectedContext = { + eventSource: 'payments', + version: pkg.version, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:72.0) Gecko/20100101 Firefox/72.0', + deviceId: '0123456789abcdef0123456789abcdef', + flowBeginTime: 1570000000000, + flowId: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + lang: 'gd', + }; + mockAmplitudeConfig.rawEvents = true; + amplitude(mocks.event, mocks.request, { + ...mocks.data, + useless: 'junk', + lang: 'gd', + }); + expect(log.info).toHaveBeenCalledTimes(2); + expect(log.info.mock.calls[0][0]).toMatch('rawAmplitudeData'); + expect(log.info.mock.calls[0][1]).toEqual({ + event: mocks.event, + context: expectedContext, + }); + expect(statsd.increment).toHaveBeenCalledTimes(2); + expect(statsd.increment.mock.calls[0][0]).toBe('amplitude.event.raw'); + expect(statsd.increment.mock.calls[1][0]).toBe('amplitude.event'); + }); describe('validates inputs', () => { it('returns if `event` is missing', () => { amplitude(undefined, mocks.request, mocks.data); @@ -182,25 +210,6 @@ describe('lib/amplitude', () => { expect(log.info).toHaveBeenCalledTimes(1); }); }); - describe('respects do not track', () => { - describe('when dnt is not set', () => { - it('does not call the filtering function', () => { - amplitude(mocks.event, mocks.request, mocks.data); - expect(mockDntFn).toHaveBeenCalledTimes(0); - }); - }); - - describe('when dnt is set', () => { - it('calls the filtering function', () => { - amplitude( - mocks.event, - { ...mocks.request, headers: { ...mocks.request.headers, dnt: '1' } }, - mocks.data - ); - expect(mockDntFn).toHaveBeenCalledTimes(2); - }); - }); - }); describe('responds to configuration', () => { it('does not perform validation when config flag is set to false', () => { mockAmplitudeConfig.schemaValidation = false; diff --git a/packages/fxa-payments-server/src/lib/mock-data.tsx b/packages/fxa-payments-server/src/lib/mock-data.tsx index 833b4224549..d49ada88292 100644 --- a/packages/fxa-payments-server/src/lib/mock-data.tsx +++ b/packages/fxa-payments-server/src/lib/mock-data.tsx @@ -232,6 +232,16 @@ export const MOCK_GENERAL_PAYPAL_ERROR = { code: 'general-paypal-error', }; +export const MOCK_CURRENCY_ERROR = { + code: '400', + errno: 130, + error: 'Bad Request', + message: 'Funding source country does not match plan currency.', + info: 'https://mozilla.github.io/ecosystem-platform/api#section/Response-format', + currency: 'usd', + country: 'RO', +}; + export const IAP_GOOGLE_SUBSCRIPTION = { _subscription_type: MozillaSubscriptionTypes.IAP_GOOGLE, product_id: 'prod_123', diff --git a/packages/fxa-payments-server/src/routes/Checkout/index.test.tsx b/packages/fxa-payments-server/src/routes/Checkout/index.test.tsx index e4f66fc30d7..17614f9d3a8 100644 --- a/packages/fxa-payments-server/src/routes/Checkout/index.test.tsx +++ b/packages/fxa-payments-server/src/routes/Checkout/index.test.tsx @@ -31,6 +31,7 @@ import { AppContextType } from '../../lib/AppContext'; import { CONFIRM_CARD_RESULT, CUSTOMER, + MOCK_CURRENCY_ERROR, MOCK_FXA_POST_PASSWORDLESS_SUB_ERROR, MOCK_GENERAL_PAYPAL_ERROR, MOCK_STRIPE_CARD_ERROR, @@ -418,6 +419,29 @@ describe('routes/Checkout', () => { ); }); + it('displays an error about the wrong currency if there is a currency mismatch', async () => { + (apiCreateSubscriptionWithPaymentMethod as jest.Mock) + .mockClear() + .mockRejectedValue(MOCK_CURRENCY_ERROR); + + await act(async () => { + render(); + }); + await fillOutZeForm(); + await waitForExpect(() => { + expect(apiCreatePasswordlessAccount).toHaveBeenCalledWith({ + email: newAccountEmail, + clientId: mockConfig.servers.oauth.clientId, + }); + }); + + const paymentErrorComponent = screen.getByTestId('payment-error'); + expect(paymentErrorComponent).toBeInTheDocument(); + expect(paymentErrorComponent).toHaveTextContent( + getErrorMessage(MOCK_CURRENCY_ERROR) + ); + }); + describe('newsletter', () => { it('POSTs to /newsletters if the newsletter checkbox is checked when subscription succeeds', async () => { await act(async () => { diff --git a/packages/fxa-payments-server/src/routes/Checkout/index.tsx b/packages/fxa-payments-server/src/routes/Checkout/index.tsx index 2a6e2f8abfd..d15be55be12 100644 --- a/packages/fxa-payments-server/src/routes/Checkout/index.tsx +++ b/packages/fxa-payments-server/src/routes/Checkout/index.tsx @@ -72,7 +72,7 @@ const PaypalButton = React.lazy(() => import('../../components/PayPalButton')); const NewsletterErrorAlertBar = () => { return ( diff --git a/packages/fxa-payments-server/src/routes/Product/index.tsx b/packages/fxa-payments-server/src/routes/Product/index.tsx index 36e56814856..57d05ddb1c1 100644 --- a/packages/fxa-payments-server/src/routes/Product/index.tsx +++ b/packages/fxa-payments-server/src/routes/Product/index.tsx @@ -29,8 +29,12 @@ import SubscriptionChangeRoadblock from './SubscriptionChangeRoadblock'; import { SubscriptionUpdateEligibility, WebSubscription, + IapSubscription, } from 'fxa-shared/subscriptions/types'; -import { isWebSubscription } from 'fxa-shared/subscriptions/type-guards'; +import { + isWebSubscription, + isIapSubscription, +} from 'fxa-shared/subscriptions/type-guards'; import { findCustomerIapSubscriptionByProductId } from '../../lib/customer'; import IapRoadblock from './IapRoadblock'; import { CouponDetails } from 'fxa-shared/dto/auth/payments/coupon'; @@ -175,25 +179,15 @@ export const Product = ({ const [planUpgradeEligibility, setPlanUpgradeEligibility] = useState('invalid'); - const checkPlanUpgradeEligibility = async (planId: string) => { - try { - const planUpgradeDetails = await apiFetchPlanUpgradeEligibility(planId); - const eligibility = await planUpgradeDetails.eligibility; - - return eligibility; - } catch (err) { - throw err; - } - }; - // Fetch plan update eligibility useEffect(() => { (async () => { if (selectedPlan) { try { - const eligibilityResult = await checkPlanUpgradeEligibility( + const planUpgradeDetails = await apiFetchPlanUpgradeEligibility( selectedPlan.plan_id ); + const eligibilityResult = await planUpgradeDetails.eligibility; setPlanUpgradeEligibility(eligibilityResult); } catch (err) { setPlanUpgradeEligibility('invalid'); @@ -281,22 +275,56 @@ export const Product = ({ // if desired product is already subscribed to, show iap already subscribed error // else, product is not subscribed to, but on same product set/might be eligible for upgrade // show iap upgrade contact support error messaging - const iapErrorMessageCode = () => { - return iapSubscription - ? 'iap_already_subscribed' // already subscribed to this product - : 'iap_upgrade_contact_support'; // different product, but same product set/eligible for upgrade - }; - if (planUpgradeEligibility === 'blocked_iap') { + // Get plan customer is blocked on + const currentPlan = () => { + if (selectedPlan.product_metadata !== null) { + const iapSubscriptions = (customerSubscriptions || []).filter((s) => + isIapSubscription(s) + ) as IapSubscription[]; + + for (const customerSubscription of iapSubscriptions) { + const subscriptionPlanInfo = + plansById[customerSubscription.price_id]; + + const currentPlanProductSet: Array = + subscriptionPlanInfo.metadata.productSet || []; + const selectedPlanProductSet: Array = selectedPlan + .product_metadata.productSet + ? selectedPlan.product_metadata.productSet.split(',') + : []; + + if ( + currentPlanProductSet.length !== 0 && + selectedPlanProductSet.length !== 0 + ) { + if ( + selectedPlanProductSet.some( + (product: string) => + currentPlanProductSet.indexOf(product) >= 0 + ) + ) { + return subscriptionPlanInfo.plan; + } + } + } + } + + return selectedPlan; + }; + return ( ); diff --git a/packages/fxa-shared/index.ts b/packages/fxa-shared/index.ts index 7b5797e9d7f..8a34289ecf5 100644 --- a/packages/fxa-shared/index.ts +++ b/packages/fxa-shared/index.ts @@ -10,7 +10,6 @@ import featureFlags from './feature-flags'; import { localizeTimestamp } from './l10n/localizeTimestamp'; import supportedLanguages from './l10n/supportedLanguages.json'; import amplitude from './metrics/amplitude'; -import { filterDntValues } from './metrics/dnt'; import flowPerformance from './metrics/flow-performance'; import navigationTimingSchema from './metrics/navigation-timing-validation'; import userAgent from './metrics/user-agent'; @@ -37,7 +36,6 @@ module.exports = { }, metrics: { amplitude, - filterDntValues, flowPerformance, navigationTimingSchema, userAgent, diff --git a/packages/fxa-shared/metrics/dnt.ts b/packages/fxa-shared/metrics/dnt.ts deleted file mode 100644 index 05a008b11f0..00000000000 --- a/packages/fxa-shared/metrics/dnt.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -const denyPrefixes = ['entrypoint', 'utm']; -const denyNames = ['product_id', 'plan_id', 'productId', 'planId']; - -const startsWithPrefix = (key: string) => - denyPrefixes.some((prefix) => key.startsWith(prefix)); - -export const filterDntValues = (metricsData: { [keys: string]: unknown }) => - Object.entries(metricsData).reduce((acc, [k, v]) => { - if (startsWithPrefix(k) || denyNames.includes(k)) { - return acc; - } - - acc[k] = v; - return acc; - }, {} as { [keys: string]: unknown }); diff --git a/packages/fxa-shared/payments/iap/apple-app-store/subscription-purchase.ts b/packages/fxa-shared/payments/iap/apple-app-store/subscription-purchase.ts index 7b397674fd7..425ae0005c0 100644 --- a/packages/fxa-shared/payments/iap/apple-app-store/subscription-purchase.ts +++ b/packages/fxa-shared/payments/iap/apple-app-store/subscription-purchase.ts @@ -103,6 +103,7 @@ export class AppStoreSubscriptionPurchase { latestNotificationSubtype?: NotificationSubtype; private offerType?: OfferType; private offerIdentifier?: string; + private purchaseDate?: number; private revocationDate?: number; private revocationReason?: number; @@ -156,6 +157,9 @@ export class AppStoreSubscriptionPurchase { if (renewalInfo.offerType) { purchase.offerType = renewalInfo.offerType; } + if (transactionInfo.purchaseDate) { + purchase.purchaseDate = transactionInfo.purchaseDate; + } if (transactionInfo.revocationDate) { purchase.revocationDate = transactionInfo.revocationDate; } diff --git a/packages/fxa-shared/test/metrics/dnt.ts b/packages/fxa-shared/test/metrics/dnt.ts deleted file mode 100644 index cda849bf89e..00000000000 --- a/packages/fxa-shared/test/metrics/dnt.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import { assert } from 'chai'; -import { filterDntValues } from '../../metrics/dnt'; - -describe('Do Not Track data filter', () => { - const properties = { - entrypoint: 'sync', - entrypoint_experiment: 'testo', - entrypoint_testo: 'another', - flowId: 'ddff00aa', - product_id: 'abcxyz', - productId: 'abcxyz', - plan_id: 'quux', - planId: 'quux', - service: 'sync', - utm_campaign: 'mr2022', - utm_content: 'none', - utm_gobbledygook: 'donottrack', - }; - - it('filters out DNT properties', () => { - const filtered = filterDntValues(properties); - assert.deepEqual(filtered, { - flowId: 'ddff00aa', - service: 'sync', - }); - }); -});