Skip to content

Commit

Permalink
feat(subscriptions): guess a language tag from plan metadata
Browse files Browse the repository at this point in the history
Because:
 - we want to move localizable content strings from Stripe plan metadata
   into a product configuration Firestore document dictionary keyed by a
   language tag

This commit:
 - try to guess the language of a plan from its details with one or more
   of Google Translate, the plan title, and the plan currency
  • Loading branch information
chenba committed May 4, 2022
1 parent 583aab3 commit fcd6d0f
Show file tree
Hide file tree
Showing 7 changed files with 492 additions and 70 deletions.
1 change: 1 addition & 0 deletions packages/fxa-auth-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@fluent/langneg": "^0.6.1",
"@google-cloud/bigquery": "^5.12.0",
"@google-cloud/firestore": "^5.0.2",
"@google-cloud/translate": "^6.3.1",
"@googlemaps/google-maps-services-js": "^3.3.13",
"@hapi/hapi": "^20.2.1",
"@hapi/hawk": "^8.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const parseDryRun = (dryRun: boolean | string) => {
};

async function init() {
if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) {
throw new Error(
'Did you forget to set GOOGLE_APPLICATION_CREDENTIALS for GCP API access?'
);
}

program
.version(pckg.version)
.option(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/* 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 { v2 as CloudTranslate } from '@google-cloud/translate';
import { Stripe } from 'stripe';

/**
* This lib attempts to detect the (human) language used in a Stripe plan's
* metadata.
*
* The language will be determined with one or more of the following:
* - the language detected by Google Cloud Translation from the plan's product
* details metadata
* - the plan's title
* - the plan's currency
*
* There is no direct connection between a currency and a localised language,
* but with the special case of Switzerland (the only one at the time of
* writing), this is the sure way to detect for Swiss language tags.
*/

const MIN_CONFIDENCE = 0.51;
export const PLAN_EN_LANG_ERROR = 'Plan specific en metadata';
const { Translate } = CloudTranslate;
const translate = new Translate();
let locales: string[];

const initLocales = (x: string[]) => {
locales = x;
};

const getMetadataProductDetails = (metadata: Stripe.Metadata) =>
Object.entries(metadata)
.reduce((acc, [k, v]) => {
if (k.startsWith('product:detail')) {
acc.push(v);
}
return acc;
}, [])
.join(' ');

const searchTagsInLocales = (tags: string[]) => {
for (const t of tags) {
if (locales.includes(t.toLowerCase())) {
return t;
}
}
};

const findLocaleInTitle = (lang: string, planTitle: string) => {
if (!planTitle) {
return lang;
}

const words = planTitle.split(' ');

// the entire tag is in the title
const potentialLanguageTags = words.filter((x) =>
x.toLowerCase().startsWith(`${lang}-`)
);
const tagFromTitle = searchTagsInLocales(potentialLanguageTags);
if (tagFromTitle) {
return tagFromTitle;
}

// a sub tag is in the title
const searchTerms = words
.filter((x) => x.toLowerCase() !== lang)
.map((t) => `${lang}-${t}`);
const tagWithSubtagFromTitle = searchTagsInLocales(searchTerms);
if (tagWithSubtagFromTitle) {
return tagWithSubtagFromTitle;
}

// default to the detected lang
return lang;
};

const mapCurrencyToLocale = (currency: string) => {
const currencyToLocaleMap: { [key: string]: string } = { chf: 'CH' };
if (currencyToLocaleMap[currency.toLowerCase()]) {
return currencyToLocaleMap[currency.toLowerCase()];
}
};

const formatLanguageTag = (tag: string) =>
tag
.split('-')
.map((x, idx) => (idx === 0 ? x.toLowerCase() : x))
.join('-');

// English is the default and should be already set at the product
// level. But maybe this plan offers some different features or
// benefits, or, it's locale specific.
const handleEnglishPlan = (plan: Partial<Stripe.Plan>) => {
const planDetails = getMetadataProductDetails(plan.metadata!);
const productDetails = getMetadataProductDetails(
(plan.product as Stripe.Product).metadata
);

// just a copy of the product's metadata apparently
if (planDetails === productDetails) {
return;
}

let lang = findLocaleInTitle('en', plan.nickname!);

if (lang === 'en') {
// the plan's en strings are different than the product's, so we save
// this on the plan.
//
// this is not exactly an error, but script in which this is used is saving
// locale specific strings to a _product_ configuration document; this is
// an exception to that flow: we want to save these strings to the plan
// configuration instead.
throw new Error(PLAN_EN_LANG_ERROR);
}

// a localised en tag
return formatLanguageTag(lang);
};

export const getLanguageTagFromPlanMetadata = async (
plan: Stripe.Plan,
locales: string[]
) => {
initLocales(locales);
const planDetails = getMetadataProductDetails(plan.metadata!);

if (planDetails) {
const detectionResult = await translate.detect(planDetails);

if (detectionResult[0].confidence < MIN_CONFIDENCE) {
throw new Error('Google Translate result confidence level too low');
}

if (detectionResult[0].language === 'en') {
return handleEnglishPlan(plan);
}

let lang = findLocaleInTitle(detectionResult[0].language, plan.nickname!);

// no subtag, extra step of checking currency
if (!lang.includes('-')) {
const subtagFromCurrency = mapCurrencyToLocale(plan.currency);
if (subtagFromCurrency) {
lang = `${lang}-${subtagFromCurrency}`;
}
}

return formatLanguageTag(lang);
}
};

export default { PLAN_EN_LANG_ERROR, getLanguageTagFromPlanMetadata };
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,20 @@ import { PlanConfig } from 'fxa-shared/subscriptions/configuration/plan';
import { ProductConfig } from 'fxa-shared/subscriptions/configuration/product';
import { StripeHelper } from '../../lib/payments/stripe';
import { commaSeparatedListToArray } from '../../lib/payments/utils';
import {
PLAN_EN_LANG_ERROR,
getLanguageTagFromPlanMetadata,
} from './plan-language-tags-guesser';

const DEFAULT_LOCALE = 'en';

function isGoogleTranslationApiError(err: any) {
return (
err.code >= 400 &&
err.response?.request?.href?.includes('translation.googleapis')
);
}

/**
* Handles converting Stripe Products and Plans to Firestore ProductConfig
* and PlanConfig Firestore documents. Updates existing documents if they
Expand Down Expand Up @@ -237,46 +248,34 @@ export class StripeProductsAndPlansConverter {
return productConfig;
}

/**
* Infer a locale (language only or language and region) from a Stripe Plan.
* TODO: #12053: Improve heuristics
*/
findLocaleStringFromStripePlan(plan: Stripe.Plan): null | string {
// Try to extract a locale from the plan nickname
const { nickname } = plan;
let locale = nickname
?.split(' ')
.filter((w) => this.supportedLanguages.includes(w.toLowerCase()));
if (locale && locale.length > 0) {
return locale[0];
}
return null;
metadataToLocalizableConfigs(stripeObject: Stripe.Product | Stripe.Plan) {
return {
uiContent: this.uiContentMetadataToUiContentConfig(stripeObject),
urls: this.urlMetadataToUrlConfig(stripeObject),
support: this.supportMetadataToSupportConfig(stripeObject),
};
}

/**
* Extract localized data from a Stripe Plan and convert it to
* ProductConfig.locales
*/
stripePlanLocalesToProductConfigLocales(
async stripePlanLocalesToProductConfigLocales(
plan: Stripe.Plan
): ProductConfig['locales'] {
): Promise<ProductConfig['locales']> {
const locales: ProductConfig['locales'] = {};
const localeStr = this.findLocaleStringFromStripePlan(plan);
const localeStr = await getLanguageTagFromPlanMetadata(
plan,
this.supportedLanguages
);
// These keys exist on the top level of ProductConfig for the default locale
if (
!localeStr ||
localeStr.toLowerCase() === DEFAULT_LOCALE.toLowerCase()
) {
return locales;
}
const uiContent = this.uiContentMetadataToUiContentConfig(plan);
const urls = this.urlMetadataToUrlConfig(plan);
const support = this.supportMetadataToSupportConfig(plan);
locales[localeStr] = {
uiContent,
urls,
support,
};
locales[localeStr] = this.metadataToLocalizableConfigs(plan);
return locales;
}

Expand Down Expand Up @@ -382,13 +381,37 @@ export class StripeProductsAndPlansConverter {
for await (const plan of this.stripeHelper.stripe.plans.list({
product: product.id,
})) {
this.stripePlanLocalesToProductConfigLocales(plan);
productConfig.locales = {
...productConfig.locales,
...this.stripePlanLocalesToProductConfigLocales(plan),
};
let planEnLocale;

try {
productConfig.locales = {
...productConfig.locales,
...(await this.stripePlanLocalesToProductConfigLocales(plan)),
};
} catch (err) {
if (err.message === PLAN_EN_LANG_ERROR) {
planEnLocale = {
[DEFAULT_LOCALE]: this.metadataToLocalizableConfigs(plan),
};
} else if (isGoogleTranslationApiError(err)) {
throw err;
} else {
this.log.error(
'StripeProductsAndPlansConverter.guessLanguageError',
{
error: err.message,
stripePlanId: plan.id,
stripeProductId: product.id,
}
);
}
}

try {
const planConfig = this.stripePlanToPlanConfig(plan);
if (planEnLocale) {
planConfig.locales = planEnLocale;
}
// If a planConfig doc already exists, update it rather than creating
// a new doc
const existingPlanConfigId =
Expand Down Expand Up @@ -447,6 +470,10 @@ export class StripeProductsAndPlansConverter {
);
}
} catch (error) {
if (isGoogleTranslationApiError(error)) {
throw new Error(`Google Translation API error: ${error.message}`);
}

this.log.error('StripeProductsAndPlansConverter.convertProductError', {
error: error.message,
stripeProductId: product.id,
Expand Down
Loading

0 comments on commit fcd6d0f

Please sign in to comment.