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',
- });
- });
-});