From c760b8137b339fa30cfa644ee65dbd3d2150a66d Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Dec 2024 13:23:51 +0100 Subject: [PATCH 1/8] Fix checkout.session.completed handler to properly handle buying extra credits where stripe customer is not present --- .../checkout-session-completed.ts | 31 ++++++++++++------- .../billing/stripe-webhook-handlers/index.ts | 2 +- .../profile/profile-subscription.gts | 13 +++++++- packages/host/app/services/billing-service.ts | 6 ++-- packages/matrix/helpers/index.ts | 16 +++++----- .../tests/registration-with-token.spec.ts | 6 ++-- .../tests/registration-without-token.spec.ts | 4 +-- packages/realm-server/tests/billing-test.ts | 9 +++--- .../realm-server/tests/realm-server-test.ts | 4 +-- packages/runtime-common/utils.ts | 23 ++++++++++---- 10 files changed, 74 insertions(+), 40 deletions(-) diff --git a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts index 38858cca5c..84875bc85b 100644 --- a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts +++ b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts @@ -1,8 +1,9 @@ -import { type DBAdapter } from '@cardstack/runtime-common'; +import { decodeWebSafeBase64, type DBAdapter } from '@cardstack/runtime-common'; import { addToCreditsLedger, getCurrentActiveSubscription, getMostRecentSubscriptionCycle, + getUserByMatrixUserId, getUserByStripeId, insertStripeEvent, markStripeEventAsProcessed, @@ -28,23 +29,31 @@ export async function handleCheckoutSessionCompleted( await txManager.withTransaction(async () => { await insertStripeEvent(dbAdapter, event); - const stripeCustomerId = event.data.object.customer; - const matrixUserName = event.data.object.client_reference_id; + let stripeCustomerId = event.data.object.customer; + let encodedMatrixId = event.data.object.client_reference_id; + let matrixUserId = decodeWebSafeBase64(encodedMatrixId); - if (matrixUserName) { - // The matrix user id was encoded to be alphanumeric by replacing + with - and / with _ - // Now we need to reverse that encoding to get back the original base64 string - const base64MatrixUserId = matrixUserName - .replace(/-/g, '+') - .replace(/_/g, '/'); - const matrixUserId = Buffer.from(base64MatrixUserId, 'base64').toString( - 'utf8', + if (!matrixUserId) { + throw new Error( + 'No matrix user id found in checkout session completed event - this should be populated using client_reference_id query param in the payment link', ); + } + + // Stripe customer id will be present when user is subscribing to the free plan, but not when they are adding extra credits + if (stripeCustomerId) { await updateUserStripeCustomerId( dbAdapter, matrixUserId, stripeCustomerId, ); + } else { + let user = await getUserByMatrixUserId(dbAdapter, matrixUserId); + + if (!user) { + throw new Error(`User not found for matrix user id: ${matrixUserId}`); + } + + stripeCustomerId = user.stripeCustomerId; } let creditReloadAmount = diff --git a/packages/billing/stripe-webhook-handlers/index.ts b/packages/billing/stripe-webhook-handlers/index.ts index cdf125974a..5823d40526 100644 --- a/packages/billing/stripe-webhook-handlers/index.ts +++ b/packages/billing/stripe-webhook-handlers/index.ts @@ -79,7 +79,7 @@ export type StripeCheckoutSessionCompletedWebhookEvent = StripeEvent & { id: string; object: 'checkout.session'; client_reference_id: string; - customer: string; + customer: string | null; // string when payment link is for subcribing to the free plan, null when buying extra credits metadata: | { credit_reload_amount: string; diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts index 0e165d3ee6..98ae1a00ab 100644 --- a/packages/host/app/components/operator-mode/profile/profile-subscription.gts +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -8,8 +8,11 @@ import { } from '@cardstack/boxel-ui/components'; import { IconHexagon } from '@cardstack/boxel-ui/icons'; +import { encodeWebSafeBase64 } from '@cardstack/runtime-common'; + import WithSubscriptionData from '@cardstack/host/components/with-subscription-data'; import BillingService from '@cardstack/host/services/billing-service'; +import MatrixService from '@cardstack/host/services/matrix-service'; interface Signature { Args: {}; @@ -71,7 +74,7 @@ export default class ProfileSubscription extends Component { @as='anchor' @kind='secondary-light' @size='extra-small' - @href={{paymentLink.url}} + @href={{this.urlWithClientReferenceId paymentLink.url}} target='_blank' data-test-pay-button={{index}} >Pay @@ -83,6 +86,7 @@ export default class ProfileSubscription extends Component { + - - @service private declare billingService: BillingService; - @service private declare matrixService: MatrixService; - - urlWithClientReferenceId(url: string) { - return `${url}?client_reference_id=${encodeWebSafeBase64( - this.matrixService.userId as string, - )}`; - } } diff --git a/packages/runtime-common/utils.ts b/packages/runtime-common/utils.ts index cbe102e469..a16f945fb2 100644 --- a/packages/runtime-common/utils.ts +++ b/packages/runtime-common/utils.ts @@ -20,21 +20,44 @@ export async function retry( return null; } -export function encodeWebSafeBase64(decoded: string) { - return ( - Buffer.from(decoded) - .toString('base64') - // Replace + with - and / with _ to make base64 URL-safe (this is a requirement for client_reference_id query param in Stripe payment link) - // Then remove any trailing = padding characters that are added by base64 encoding - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - ); +/** + * Encodes a string to web-safe base64 format. + * Standard base64 uses '+', '/' and '=' which can cause issues in URLs and other web contexts. + * This function replaces them with URL-safe alternatives: + * '+' -> '-' + * '/' -> '_' + * '=' padding is removed + */ +export function encodeWebSafeBase64(text: string): string { + return Buffer.from(text) + .toString('base64') + .replace(/\+/g, '-') // Convert + to - for URL safety + .replace(/\//g, '_') // Convert / to _ for URL safety + .replace(/=/g, ''); // Remove padding = signs } -export function decodeWebSafeBase64(encoded: string) { - return Buffer.from( - encoded.replace(/-/g, '+').replace(/_/g, '/'), - 'base64', - ).toString('utf8'); +/** + * Decodes a web-safe base64 string back to its original text. + * Reverses the character substitutions made in encoding: + * '-' -> '+' + * '_' -> '/' + * Restores required padding (=) based on string length before decoding. + */ +export function decodeWebSafeBase64(encoded: string): string { + let base64 = encoded + .replace(/-/g, '+') // Restore + from - + .replace(/_/g, '/'); // Restore / from _ + + // Base64 strings should have a length that's a multiple of 4. + // If not, we need to add back the padding that was removed. + switch (base64.length % 4) { + case 2: + base64 += '=='; + break; + case 3: + base64 += '='; + break; + } + + return Buffer.from(base64, 'base64').toString('utf-8'); } From 97897f102d83b84fc00bb36f710757d8c80846b6 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Dec 2024 14:13:33 +0100 Subject: [PATCH 3/8] Add a test for client_reference_id encoding/decoding --- packages/realm-server/tests/billing-test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 200f94cafb..13059e6bdf 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -1,4 +1,9 @@ -import { encodeWebSafeBase64, param, query } from '@cardstack/runtime-common'; +import { + decodeWebSafeBase64, + encodeWebSafeBase64, + param, + query, +} from '@cardstack/runtime-common'; import { module, test } from 'qunit'; import { fetchSubscriptionsByUserId, @@ -81,6 +86,19 @@ async function fetchCreditsLedgerByUser( ); } +module('billing utils', function () { + test('encoding client_reference_id to be web safe in payment links', function (assert) { + assert.strictEqual( + decodeWebSafeBase64(encodeWebSafeBase64('@mike_1:cardstack.com')), + '@mike_1:cardstack.com', + ); + assert.strictEqual( + decodeWebSafeBase64(encodeWebSafeBase64('@hans.müller:matrix.de')), + '@hans.müller:matrix.de', + ); + }); +}); + module('billing', function (hooks) { let dbAdapter: PgAdapter; From 3a636ba193318dde06109e4eb9450461eccece81 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Mon, 2 Dec 2024 14:26:01 +0100 Subject: [PATCH 4/8] Fix test --- .../operator-mode-acceptance-test.gts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts index 45bfec247a..1000862424 100644 --- a/packages/host/tests/acceptance/operator-mode-acceptance-test.gts +++ b/packages/host/tests/acceptance/operator-mode-acceptance-test.gts @@ -13,7 +13,11 @@ import { module, test } from 'qunit'; import { FieldContainer } from '@cardstack/boxel-ui/components'; -import { baseRealm, primitive } from '@cardstack/runtime-common'; +import { + baseRealm, + encodeWebSafeBase64, + primitive, +} from '@cardstack/runtime-common'; import { Submodes } from '@cardstack/host/components/submode-switcher'; import { @@ -937,15 +941,30 @@ module('Acceptance | operator mode tests', function (hooks) { assert.dom('[data-test-payment-link]').exists({ count: 3 }); assert .dom('[data-test-pay-button="0"]') - .hasAttribute('href', 'https://extra-credits-payment-link-1250'); + .hasAttribute( + 'href', + `https://extra-credits-payment-link-1250?client_reference_id=${encodeWebSafeBase64( + '@testuser:staging', + )}`, + ); 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'); + .hasAttribute( + 'href', + `https://extra-credits-payment-link-15000?client_reference_id=${encodeWebSafeBase64( + '@testuser:staging', + )}`, + ); 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'); + .hasAttribute( + 'href', + `https://extra-credits-payment-link-80000?client_reference_id=${encodeWebSafeBase64( + '@testuser:staging', + )}`, + ); assert.dom('[data-test-pay-button="2"]').hasAttribute('target', '_blank'); // out of credit From 867e4b6395f057e803109cf807b816d739f25190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Tue, 3 Dec 2024 09:14:16 +0100 Subject: [PATCH 5/8] Fix typo Co-authored-by: Luke Melia --- packages/billing/stripe-webhook-handlers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/billing/stripe-webhook-handlers/index.ts b/packages/billing/stripe-webhook-handlers/index.ts index 5823d40526..c718cdfd6a 100644 --- a/packages/billing/stripe-webhook-handlers/index.ts +++ b/packages/billing/stripe-webhook-handlers/index.ts @@ -79,7 +79,7 @@ export type StripeCheckoutSessionCompletedWebhookEvent = StripeEvent & { id: string; object: 'checkout.session'; client_reference_id: string; - customer: string | null; // string when payment link is for subcribing to the free plan, null when buying extra credits + customer: string | null; // string when payment link is for subscribing to the free plan, null when buying extra credits metadata: | { credit_reload_amount: string; From 7124290f84bfaa381859f6ff1067e670ac36cb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matic=20Jurgli=C4=8D?= Date: Tue, 3 Dec 2024 09:18:30 +0100 Subject: [PATCH 6/8] Better style for bound functions Co-authored-by: Luke Melia --- .../operator-mode/profile/profile-subscription.gts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/host/app/components/operator-mode/profile/profile-subscription.gts b/packages/host/app/components/operator-mode/profile/profile-subscription.gts index 33fdb19e59..633efc284a 100644 --- a/packages/host/app/components/operator-mode/profile/profile-subscription.gts +++ b/packages/host/app/components/operator-mode/profile/profile-subscription.gts @@ -24,12 +24,11 @@ export default class ProfileSubscription extends Component { @service private declare billingService: BillingService; @service private declare matrixService: MatrixService; - @action - urlWithClientReferenceId(url: string) { + urlWithClientReferenceId = (url: string) => { return `${url}?client_reference_id=${encodeWebSafeBase64( this.matrixService.userId as string, )}`; - } + };