Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/payments/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { StrapiClientConfig } from '@fxa/shared/cms';
import { MySQLConfig } from '@fxa/shared/db/mysql/core';
import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks';
import { StatsDConfig } from '@fxa/shared/metrics/statsd';
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
import { FirestoreConfig } from '@fxa/shared/db/firestore';
import { FxaOAuthConfig } from '@fxa/payments/auth';

export class RootConfig {
Expand Down
7 changes: 7 additions & 0 deletions apps/payments/next/.env
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ CHURN_INTERVENTION_CONFIG__ENABLED=
# Free Trial Config
FREE_TRIAL_CONFIG__FIRESTORE_COLLECTION_NAME=freeTrials

# Free Access Program Client Config
# NB: Ideally this matches the config in payments-api
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_CACHE_COLLECTION_NAME=subplat-free-access-program-cache
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__MEM_CACHE_T_T_L=300 # 5 minutes
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_CACHE_TTL=1800 # 30 minutes
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__FIRESTORE_OFFLINE_CACHE_TTL=604800 # 7 days

# StatsD Config
STATS_D_CONFIG__SAMPLE_RATE=
STATS_D_CONFIG__MAX_BUFFER_SIZE=
Expand Down
2 changes: 2 additions & 0 deletions apps/payments/next/app/[locale]/subscriptions/manage/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ subscription-management-page-banner-warning-link-no-payment-method = Add a payme
subscription-management-subscriptions-heading = Subscriptions
subscription-management-free-trial-heading = Free trials
subscription-management-your-free-trials-aria = Your free trials
subscription-management-free-access-heading = Services included with your account
subscription-management-your-free-access-aria = Services included with your account

# Heading for mobile only quick links menu
subscription-management-jump-to-heading = Jump to
Expand Down
40 changes: 40 additions & 0 deletions apps/payments/next/app/[locale]/subscriptions/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
import {
Banner,
BannerVariant,
FreeAccessContent,
formatPlanInterval,
FreeTrialContent,
getCardIcon,
Expand Down Expand Up @@ -76,6 +77,7 @@ export default async function Manage({
appleIapSubscriptions,
googleIapSubscriptions,
trialSubscriptions,
freeAccess,
} = await getSubManPageContentAction(
{ ...resolvedParams },
{ ...resolvedSearchParams },
Expand Down Expand Up @@ -268,6 +270,44 @@ export default async function Manage({
</nav>
)}

{freeAccess && freeAccess.length > 0 && (
<section
id="free-access"
className="scroll-mt-16"
aria-labelledby="free-access-heading"
>
<h2
id="free-access-heading"
className="font-bold px-4 pt-8 pb-4 text-lg tablet:px-6"
>
{l10n.getString(
'subscription-management-free-access-heading',
'Services included with your account'
)}
</h2>
<ul
aria-label={l10n.getString(
'subscription-management-your-free-access-aria',
'Services included with your account'
)}
>
{freeAccess.map((grant, index) => (
<li
key={`${grant.offeringApiIdentifier}-${index}`}
aria-labelledby={`${grant.offeringApiIdentifier}-free-access-information`}
className="leading-6 pb-4 last:pb-0"
>
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
<FreeAccessContent freeAccess={grant} />
</div>
</div>
</li>
))}
</ul>
</section>
)}

{trialSubscriptions.length > 0 && (
<section
id="free-trial"
Expand Down
14 changes: 14 additions & 0 deletions libs/free-access-program/.swcrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
}
}
}
45 changes: 45 additions & 0 deletions libs/free-access-program/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# free-access-program

Firestore-backed projection of Strapi access records that grant
free access to RP services. Each document carries a per-(email, entitlement)
`expiresAt` enforced by a Firestore TTL policy; when a document is removed
(TTL reap or manual delete), the resulting Eventarc `onDelete` event fans out
a `subscription:update` notification so consumers re-resolve the user's
capabilities.

This library owns the read/write data layer only. The Strapi reconciler and
the Eventarc → SNS notifier are wired up in the consuming service
(`payments-api`).

## Building

Run `nx build free-access-program` to build the library.

## Running unit tests

Run `nx test-unit free-access-program` to execute the unit tests via
[Jest](https://jestjs.io).

## Reconcile script

Run `nx run free-access-program:reconcile-all` to invoke a full Strapi →
Firestore reconciliation pass. Intended to be wired to an external scheduler
(Cloud Scheduler / Kubernetes CronJob) at the operator's preferred cadence.

Required env vars (same `__` separator and `key_transformer` conventions as
the payments-api `RootConfig`):

```
FIRESTORE_CONFIG__PROJECT_ID=...
FIRESTORE_CONFIG__KEY_FILENAME=... # or FIRESTORE_CONFIG__CREDENTIALS__*
STRAPI_CLIENT_CONFIG__GRAPHQL_API_URI=...
STRAPI_CLIENT_CONFIG__API_KEY=...
FREE_ACCESS_PROGRAM_CLIENT_CONFIG__COLLECTION_NAME=free-access-program
STATS_D_CONFIG__HOST=...
STATS_D_CONFIG__PORT=...
# Auth-server endpoint the notifier posts revocations to. Auth-server
# resolves email → uid, invalidates the profile cache, and broadcasts
# the `subscription:update` event over its existing SNS pipeline.
AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__BASE_URL=https://api.accounts.firefox.com
AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__SUBSCRIPTIONS_SECRET=...
```
38 changes: 38 additions & 0 deletions libs/free-access-program/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* eslint-disable */
import { readFileSync } from 'fs';
import { Config } from 'jest';

// Reading the SWC compilation config and remove the "exclude"
// for the test files to be compiled by SWC
const { exclude: _, ...swcJestConfig } = JSON.parse(
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
);

// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
if (swcJestConfig.swcrc === undefined) {
swcJestConfig.swcrc = false;
}

const config: Config = {
displayName: 'free-access-program',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
},
moduleFileExtensions: ['ts', 'js', 'html'],
testEnvironment: 'node',
coverageDirectory: '../../coverage/libs/free-access-program',
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: 'artifacts/tests/free-access-program',
outputName: 'free-access-program-jest-unit-results.xml',
},
],
],
};

export default config;
4 changes: 4 additions & 0 deletions libs/free-access-program/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@fxa/free-access-program",
"version": "0.0.1"
}
49 changes: 49 additions & 0 deletions libs/free-access-program/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "free-access-program",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/free-access-program/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"dependsOn": ["build-ts"],
"executor": "nx:run-commands",
"options": {
"command": "echo Build complete"
}
},
"build-ts": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"main": "libs/free-access-program/src/index.ts",
"outputPath": "dist/libs/free-access-program",
"tsConfig": "libs/free-access-program/tsconfig.lib.json",
"assets": [
{
"glob": "libs/free-access-program/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/free-access-program/**/*.ts",
"libs/free-access-program/package.json"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/free-access-program/jest.config.ts"
}
}
}
}
11 changes: 11 additions & 0 deletions libs/free-access-program/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* 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/. */

export * from './lib/free-access-program.client.config';
export * from './lib/free-access-program.factories';
export * from './lib/free-access-program.manager';
export * from './lib/free-access-program.reconciler.service';
export * from './lib/free-access-program.types';
export * from './lib/free-access-program.webhook.factories';
export * from './lib/free-access-program.webhook.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* 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 { faker } from '@faker-js/faker';
import { Provider } from '@nestjs/common';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString } from 'class-validator';

/**
* Config for the cached projection of free-access program grants. Mirrors
* the cache-related fields on `StrapiClientConfig` so the two caches behave
* identically by default. All TTL fields are optional and fall back to the
* Strapi-client defaults at the call site.
*/
export class FreeAccessProgramClientConfig {
/**
* Firestore collection used by `type-cacheable`'s FirestoreAdapter to
* persist the serialized snapshot for cross-instance / cold-start reads.
* Distinct from any production data collection — purely a cache store.
*/
@IsString()
public readonly firestoreCacheCollectionName!: string;

@IsOptional()
@Type(() => Number)
@IsNumber()
public readonly memCacheTTL?: number;

@IsOptional()
@Type(() => Number)
@IsNumber()
public readonly firestoreCacheTTL?: number;

@IsOptional()
@Type(() => Number)
@IsNumber()
public readonly firestoreOfflineCacheTTL?: number;
}

export const MockFreeAccessProgramClientConfig = {
firestoreCacheCollectionName: faker.string.uuid(),
} satisfies FreeAccessProgramClientConfig;

export const MockFreeAccessProgramClientConfigProvider = {
provide: FreeAccessProgramClientConfig,
useValue: MockFreeAccessProgramClientConfig,
} satisfies Provider<FreeAccessProgramClientConfig>;
Loading
Loading