diff --git a/packages/host/app/components/matrix/payment-setup.gts b/packages/host/app/components/matrix/payment-setup.gts index 3fa94cc5cf..ea5803e812 100644 --- a/packages/host/app/components/matrix/payment-setup.gts +++ b/packages/host/app/components/matrix/payment-setup.gts @@ -124,6 +124,7 @@ export default class PaymentSetup extends Component { - {{! Show credit info if the user has an active plan }} - {{#if this.plan}} -
-
- Membership Tier - - {{#if this.isLoading}} - - {{else}} - {{this.plan}} - {{/if}} - -
- Upgrade Plan -
- Monthly Credit - - {{#if this.isLoading}} - - {{else}} - - {{this.monthlyCreditText}} - {{/if}} - -
-
- Additional Credit - {{#if this.isLoading}} - - {{else}} - - {{this.extraCreditsAvailableInBalance}} - {{/if}} -
-
+ + {{! Show credit info if the user has an active plan }} + {{#if subscriptionData.hasActiveSubscription}} +
+
+ Membership Tier + {{subscriptionData.plan}} +
Buy more credits + @as='anchor' + @kind='secondary-light' + @size='small' + @disabled={{or + subscriptionData.isLoading + this.billingService.fetchingStripePaymentLinks + }} + @href={{this.billingService.customerPortalLink.url}} + target='_blank' + data-test-upgrade-plan-button + >Upgrade Plan +
+ Monthly Credit + {{subscriptionData.monthlyCredit}} +
+
+ Additional Credit + {{subscriptionData.additionalCredit}} +
+
+ Buy more credits +
-
- {{/if}} + {{/if}} + +
- constructor(...args: [any, any]) { - super(...args); - this.fetchCreditInfo.perform(); - } - - @service private declare billingService: BillingService; @service declare matrixService: MatrixService; - - private fetchCreditInfo = task(async () => { - await this.billingService.fetchSubscriptionData(); - }); + @service declare billingService: BillingService; @action private logout() { this.matrixService.logout(); } - - private get isLoading() { - return this.billingService.fetchingSubscriptionData; - } - - private get plan() { - return this.billingService.subscriptionData?.plan; - } - - private get creditsIncludedInPlanAllowance() { - return this.billingService.subscriptionData?.creditsIncludedInPlanAllowance; - } - - private get creditsAvailableInPlanAllowance() { - return this.billingService.subscriptionData - ?.creditsAvailableInPlanAllowance; - } - - private get extraCreditsAvailableInBalance() { - return this.billingService.subscriptionData?.extraCreditsAvailableInBalance; - } - - private get monthlyCreditText() { - return this.creditsAvailableInPlanAllowance != null && - this.creditsIncludedInPlanAllowance != null - ? `${this.creditsAvailableInPlanAllowance} of ${this.creditsIncludedInPlanAllowance} left` - : null; - } - - private get isOutOfCredit() { - return ( - this.isOutOfPlanCreditAllowance && - (this.extraCreditsAvailableInBalance == null || - this.extraCreditsAvailableInBalance == 0) - ); - } - - private get isOutOfPlanCreditAllowance() { - return ( - this.creditsAvailableInPlanAllowance == null || - this.creditsIncludedInPlanAllowance == null || - this.creditsAvailableInPlanAllowance <= 0 - ); - } } export class ProfileInfo extends Component { diff --git a/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts b/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts index 4a5251d3a5..56c07a37a4 100644 --- a/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts +++ b/packages/host/app/components/operator-mode/profile/profile-settings-modal.gts @@ -28,6 +28,7 @@ import { isValidPassword } from '@cardstack/host/lib/matrix-utils'; import MatrixService from '@cardstack/host/services/matrix-service'; import ProfileEmail from './profile-email'; +import ProfileSubscription from './profile-subscription'; interface Signature { Args: { @@ -126,6 +127,7 @@ export default class ProfileSettingsModal extends Component { @changeEmailComplete={{this.completeEmail}} /> {{/if}} + {{#if (or (bool this.displayNameError) (bool this.error))}}
diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts new file mode 100644 index 0000000000..0e165d3ee6 --- /dev/null +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -0,0 +1,162 @@ +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { + BoxelButton, + FieldContainer, + LoadingIndicator, +} from '@cardstack/boxel-ui/components'; +import { IconHexagon } from '@cardstack/boxel-ui/icons'; + +import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; +import BillingService from '@cardstack/host/services/billing-service'; + +interface Signature { + Args: {}; + Element: HTMLElement; +} + +export default class ProfileSubscription extends Component { + + + @service private declare billingService: BillingService; +} diff --git a/packages/host/app/components/with-subscription-data.gts b/packages/host/app/components/with-subscription-data.gts new file mode 100644 index 0000000000..c217426472 --- /dev/null +++ b/packages/host/app/components/with-subscription-data.gts @@ -0,0 +1,156 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { hash } from '@ember/helper'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; +import { cn } from '@cardstack/boxel-ui/helpers'; +import { IconHexagon } from '@cardstack/boxel-ui/icons'; + +import BillingService from '../services/billing-service'; + +import type { ComponentLike } from '@glint/template'; + +interface ValueSignature { + Args: { + tag: string; + value: string | number | null; + isOutOfCredit: boolean; + isLoading: boolean; + displayCreditIcon: boolean; + }; + Element: HTMLElement; +} + +const Value: TemplateOnlyComponent = ; + +interface WithSubscriptionDataSignature { + Args: {}; + Blocks: { + default: [ + { + hasActiveSubscription: boolean; + plan: ComponentLike; + monthlyCredit: ComponentLike; + additionalCredit: ComponentLike; + isOutOfCredit: boolean; + isLoading: boolean; + }, + ]; + }; +} + +export default class WithSubscriptionData extends Component { + @service declare billingService: BillingService; + + constructor(...args: [any, any]) { + super(...args); + this.billingService.fetchSubscriptionData(); + } + + private get isLoading() { + return this.billingService.fetchingSubscriptionData; + } + + private get plan() { + return this.billingService.subscriptionData?.plan; + } + + private get creditsIncludedInPlanAllowance() { + return this.billingService.subscriptionData?.creditsIncludedInPlanAllowance; + } + + private get creditsAvailableInPlanAllowance() { + return this.billingService.subscriptionData + ?.creditsAvailableInPlanAllowance; + } + + private get extraCreditsAvailableInBalance() { + return this.billingService.subscriptionData?.extraCreditsAvailableInBalance; + } + + private get monthlyCreditText() { + return this.creditsAvailableInPlanAllowance != null && + this.creditsIncludedInPlanAllowance != null + ? `${this.creditsAvailableInPlanAllowance} of ${this.creditsIncludedInPlanAllowance} left` + : null; + } + + private get isOutOfCredit() { + return ( + this.isOutOfPlanCreditAllowance && + (this.extraCreditsAvailableInBalance == null || + this.extraCreditsAvailableInBalance == 0) + ); + } + + private get isOutOfPlanCreditAllowance() { + return ( + this.creditsAvailableInPlanAllowance == null || + this.creditsIncludedInPlanAllowance == null || + this.creditsAvailableInPlanAllowance <= 0 + ); + } + + +} diff --git a/packages/host/app/services/billing-service.ts b/packages/host/app/services/billing-service.ts index 9190771c9f..95020570f4 100644 --- a/packages/host/app/services/billing-service.ts +++ b/packages/host/app/services/billing-service.ts @@ -5,19 +5,17 @@ import { tracked, cached } from '@glimmer/tracking'; import { dropTask } from 'ember-concurrency'; +import { trackedFunction } from 'ember-resources/util/function'; + import { SupportedMimeType, encodeToAlphanumeric, } from '@cardstack/runtime-common'; -import ENV from '@cardstack/host/config/environment'; - import NetworkService from './network'; import RealmServerService from './realm-server'; import ResetService from './reset'; -const { stripePaymentLink } = ENV; - interface SubscriptionData { plan: string | null; creditsAvailableInPlanAllowance: number | null; @@ -26,6 +24,12 @@ interface SubscriptionData { stripeCustomerId: string | null; } +interface StripeLink { + type: string; + url: string; + creditReloadAmount?: number; +} + export default class BillingService extends Service { @tracked private _subscriptionData: SubscriptionData | null = null; @@ -46,13 +50,74 @@ export default class BillingService extends Service { this._subscriptionData = null; } + get customerPortalLink() { + return this.stripeLinks.value?.customerPortalLink; + } + + get freePlanPaymentLink() { + return this.stripeLinks.value?.freePlanPaymentLink; + } + + get extraCreditsPaymentLinks() { + return this.stripeLinks.value?.extraCreditsPaymentLinks; + } + + get fetchingStripePaymentLinks() { + return this.stripeLinks.isLoading; + } + + private stripeLinks = trackedFunction(this, async () => { + let response = await this.network.fetch( + `${this.url.origin}/_stripe-links`, + { + headers: { + Accept: SupportedMimeType.JSONAPI, + 'Content-Type': 'application/json', + Authorization: `Bearer ${await this.getToken()}`, + }, + }, + ); + if (!response.ok) { + console.error( + `Failed to fetch stripe payment links for realm server ${this.url.origin}: ${response.status}`, + ); + return; + } + + let json = (await response.json()) as { + data: { + type: string; + attributes: { + url: string; + metadata?: { creditReloadAmount: number }; + }; + }[]; + }; + let links = json.data.map((data) => ({ + type: data.type, + url: data.attributes.url, + creditReloadAmount: data.attributes.metadata?.creditReloadAmount, + })) as StripeLink[]; + return { + customerPortalLink: links.find( + (link) => link.type === 'customer-portal-link', + ), + freePlanPaymentLink: links.find( + (link) => link.type === 'free-plan-payment-link', + ), + extraCreditsPaymentLinks: links.filter( + (link) => link.type === 'extra-credits-payment-link', + ), + }; + }); + getStripePaymentLink(matrixUserId: string): string { // We use the matrix user id (@username:example.com) as the client reference id for stripe // so we can identify the user payment in our system when we get the webhook // the client reference id must be alphanumeric, so we encode the matrix user id // https://docs.stripe.com/payment-links/url-parameters#streamline-reconciliation-with-a-url-parameter const clientReferenceId = encodeToAlphanumeric(matrixUserId); - return `${stripePaymentLink}?client_reference_id=${clientReferenceId}`; + return `${this.freePlanPaymentLink?.url}?client_reference_id=${clientReferenceId}`; } @cached @@ -64,11 +129,11 @@ export default class BillingService extends Service { return this.fetchSubscriptionDataTask.isRunning; } - async fetchSubscriptionData() { + fetchSubscriptionData() { if (this.subscriptionData) { return; } - await this.fetchSubscriptionDataTask.perform(); + this.fetchSubscriptionDataTask.perform(); } private async subscriptionDataRefresher() { @@ -83,31 +148,30 @@ export default class BillingService extends Service { Authorization: `Bearer ${await this.getToken()}`, }, }); - - if (response.ok) { - let json = await response.json(); - let plan = - json.included?.find((i: { type: string }) => i.type === 'plan') - ?.attributes?.name ?? null; - let creditsAvailableInPlanAllowance = - json.data?.attributes?.creditsAvailableInPlanAllowance ?? null; - let creditsIncludedInPlanAllowance = - json.data?.attributes?.creditsIncludedInPlanAllowance ?? null; - let extraCreditsAvailableInBalance = - json.data?.attributes?.extraCreditsAvailableInBalance ?? null; - let stripeCustomerId = json.data?.attributes?.stripeCustomerId ?? null; - this._subscriptionData = { - plan, - creditsAvailableInPlanAllowance, - creditsIncludedInPlanAllowance, - extraCreditsAvailableInBalance, - stripeCustomerId, - }; - } else { + if (!response.ok) { console.error( `Failed to fetch user for realm server ${this.url.origin}: ${response.status}`, ); + return; } + let json = await response.json(); + let plan = + json.included?.find((i: { type: string }) => i.type === 'plan') + ?.attributes?.name ?? null; + let creditsAvailableInPlanAllowance = + json.data?.attributes?.creditsAvailableInPlanAllowance ?? null; + let creditsIncludedInPlanAllowance = + json.data?.attributes?.creditsIncludedInPlanAllowance ?? null; + let extraCreditsAvailableInBalance = + json.data?.attributes?.extraCreditsAvailableInBalance ?? null; + let stripeCustomerId = json.data?.attributes?.stripeCustomerId ?? null; + this._subscriptionData = { + plan, + creditsAvailableInPlanAllowance, + creditsIncludedInPlanAllowance, + extraCreditsAvailableInBalance, + stripeCustomerId, + }; }); private async getToken() { diff --git a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts index 2e6771558a..45bfec247a 100644 --- a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts +++ b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts @@ -886,29 +886,67 @@ module('Acceptance | operator mode tests', function (hooks) { await click('[data-test-profile-icon-button]'); assert.dom('[data-test-profile-popover]').exists(); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('1000 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasNoClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('100'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-upgrade-plan-button]').exists(); assert.dom('[data-test-buy-more-credits]').exists(); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); - - await click('[data-test-upgrade-plan-button]'); - assert.dom('[data-test-profile-popover]').doesNotExist(); assert - .dom('[data-test-boxel-card-container]') - .hasClass('profile-settings'); + .dom('[data-test-upgrade-plan-button]') + .hasAttribute('href', 'https://customer-portal-link'); + assert + .dom('[data-test-upgrade-plan-button]') + .hasAttribute('target', '_blank'); - await click('[aria-label="close modal"]'); - await click('[data-test-profile-icon-button]'); assert.dom('[data-test-profile-popover]').exists(); await click('[data-test-buy-more-credits] button'); assert.dom('[data-test-profile-popover]').doesNotExist(); assert .dom('[data-test-boxel-card-container]') .hasClass('profile-settings'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-manage-plan-button]') + .hasAttribute('href', 'https://customer-portal-link'); + assert + .dom('[data-test-manage-plan-button]') + .hasAttribute('target', '_blank'); + assert.dom('[data-test-payment-link]').exists({ count: 3 }); + assert + .dom('[data-test-pay-button="0"]') + .hasAttribute('href', 'https://extra-credits-payment-link-1250'); + assert.dom('[data-test-pay-button="0"]').hasAttribute('target', '_blank'); + assert + .dom('[data-test-pay-button="1"]') + .hasAttribute('href', 'https://extra-credits-payment-link-15000'); + assert.dom('[data-test-pay-button="1"]').hasAttribute('target', '_blank'); + assert + .dom('[data-test-pay-button="2"]') + .hasAttribute('href', 'https://extra-credits-payment-link-80000'); + assert.dom('[data-test-pay-button="2"]').hasAttribute('target', '_blank'); // out of credit await click('[aria-label="close modal"]'); @@ -921,11 +959,19 @@ module('Acceptance | operator mode tests', function (hooks) { }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('0 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('100'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('0 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('100'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); await click('[data-test-profile-icon-button]'); @@ -936,11 +982,19 @@ module('Acceptance | operator mode tests', function (hooks) { body: JSON.stringify({ eventType: 'billing-notification' }), }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('0 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('0'); - assert.dom('[data-test-additional-credit]').hasClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('0 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('0'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasClass('out-of-credit'); await click('[data-test-profile-icon-button]'); @@ -951,11 +1005,19 @@ module('Acceptance | operator mode tests', function (hooks) { body: JSON.stringify({ eventType: 'billing-notification' }), }); await click('[data-test-profile-icon-button]'); - assert.dom('[data-test-membership-tier]').hasText('Free'); - assert.dom('[data-test-monthly-credit]').hasText('1000 of 1000 left'); - assert.dom('[data-test-monthly-credit]').hasNoClass('out-of-credit'); - assert.dom('[data-test-additional-credit]').hasText('0'); - assert.dom('[data-test-additional-credit]').hasNoClass('out-of-credit'); + assert.dom('[data-test-subscription-data="plan"]').hasText('Free'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasText('1000 of 1000 left'); + assert + .dom('[data-test-subscription-data="monthly-credit"]') + .hasNoClass('out-of-credit'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasText('0'); + assert + .dom('[data-test-subscription-data="additional-credit"]') + .hasNoClass('out-of-credit'); assert.dom('[data-test-buy-more-credits]').hasNoClass('out-of-credit'); }); }); diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index 105de85d07..3ef7de4e01 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -794,6 +794,61 @@ export function setupRealmServerEndpoints( ); }, }, + { + route: '_stripe-links', + getResponse: async function (_req: Request) { + return new Response( + JSON.stringify({ + data: [ + { + type: 'customer-portal-link', + id: '1', + attributes: { + url: 'https://customer-portal-link', + }, + }, + { + type: 'free-plan-payment-link', + id: 'plink_1QP4pEPUHhctoJxaEp1D3myQ', + attributes: { + url: 'https://free-plan-payment-link', + }, + }, + { + type: 'extra-credits-payment-link', + id: 'plink_1QP4pEPUHhctoJxaEp1D3my!', + attributes: { + url: 'https://extra-credits-payment-link-1250', + metadata: { + creditReloadAmount: 1250, + }, + }, + }, + { + type: 'extra-credits-payment-link', + id: 'plink_1QP4pEPUHhctoJxaEp1D3myP', + attributes: { + url: 'https://extra-credits-payment-link-15000', + metadata: { + creditReloadAmount: 15000, + }, + }, + }, + { + type: 'extra-credits-payment-link', + id: 'plink_1QP4pEPUHhctoJxaEp1D3my!', + attributes: { + url: 'https://extra-credits-payment-link-80000', + metadata: { + creditReloadAmount: 80000, + }, + }, + }, + ], + }), + ); + }, + }, ]; let handleRealmServerRequest = async (req: Request) => { diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index c288cc10d0..e685337a71 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -576,15 +576,6 @@ export async function assertLoggedIn(page: Page, opts?: ProfileAssertions) { } } -export async function assertPaymentSetup(page: Page, username: string) { - const stripePaymentLink = 'https://buy.stripe.com/test_4gw01WfWb2c1dBm7sv'; - const expectedLink = `${stripePaymentLink}?client_reference_id=${username}`; - await expect(page.locator('[data-test-setup-payment]')).toHaveAttribute( - 'href', - expectedLink, - ); -} - export async function setupUser( username: string, realmServer: IsolatedRealmServer, diff --git a/packages/matrix/tests/registration-with-token.spec.ts b/packages/matrix/tests/registration-with-token.spec.ts index dc25a665ed..d28f0ba2f5 100644 --- a/packages/matrix/tests/registration-with-token.spec.ts +++ b/packages/matrix/tests/registration-with-token.spec.ts @@ -17,7 +17,6 @@ import { validateEmail, gotoRegistration, assertLoggedIn, - assertPaymentSetup, assertLoggedOut, logout, login, @@ -109,10 +108,14 @@ test.describe('User Registration w/ Token - isolated realm server', () => { }, }); + await page.bringToFront(); + + await expect(page.locator('[data-test-email-validated]')).toContainText( + 'Success! Your email has been validated', + ); + // base 64 encode the matrix user id const matrixUserId = encodeToAlphanumeric('@user1:localhost'); - - await assertPaymentSetup(page, matrixUserId); await setupPayment(matrixUserId, realmServer, page); await assertLoggedIn(page, { email: 'user1@example.com', @@ -204,7 +207,6 @@ test.describe('User Registration w/ Token - isolated realm server', () => { const user2MatrixUserId = encodeToAlphanumeric('@user2:localhost'); - await assertPaymentSetup(page, user2MatrixUserId); await setupPayment(user2MatrixUserId, realmServer, page); await assertLoggedIn(page, { diff --git a/packages/matrix/tests/registration-without-token.spec.ts b/packages/matrix/tests/registration-without-token.spec.ts index 9abf91daec..d6cb118cab 100644 --- a/packages/matrix/tests/registration-without-token.spec.ts +++ b/packages/matrix/tests/registration-without-token.spec.ts @@ -15,7 +15,6 @@ import { validateEmail, gotoRegistration, assertLoggedIn, - assertPaymentSetup, setupPayment, registerRealmUsers, encodeToAlphanumeric, @@ -72,7 +71,6 @@ test.describe('User Registration w/o Token', () => { // base 64 encode the matrix user id const matrixUserId = encodeToAlphanumeric('@user1:localhost'); - await assertPaymentSetup(page, matrixUserId); await setupPayment(matrixUserId, realmServer, page); await assertLoggedIn(page); });