Skip to content

Commit

Permalink
Merge pull request #1865 from cardstack/cs-7593-buying-extra-credits-…
Browse files Browse the repository at this point in the history
…not-working

Fix checkout.session.completed handler to properly handle buying extra credits
  • Loading branch information
jurgenwerk authored Dec 3, 2024
2 parents 29abc6f + 94708b8 commit 9d1208f
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 46 deletions.
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion packages/billing/stripe-webhook-handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 subscribing to the free plan, null when buying extra credits
metadata:
| {
credit_reload_amount: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,27 @@ 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 type BillingService from '@cardstack/host/services/billing-service';
import type MatrixService from '@cardstack/host/services/matrix-service';

interface Signature {
Args: {};
Element: HTMLElement;
}

export default class ProfileSubscription extends Component<Signature> {
@service private declare billingService: BillingService;
@service private declare matrixService: MatrixService;

urlWithClientReferenceId = (url: string) => {
return `${url}?client_reference_id=${encodeWebSafeBase64(
this.matrixService.userId as string,
)}`;
};

<template>
<WithSubscriptionData as |subscriptionData|>
<FieldContainer
Expand Down Expand Up @@ -71,7 +83,7 @@ export default class ProfileSubscription extends Component<Signature> {
@as='anchor'
@kind='secondary-light'
@size='extra-small'
@href={{paymentLink.url}}
@href={{this.urlWithClientReferenceId paymentLink.url}}
target='_blank'
data-test-pay-button={{index}}
>Pay</BoxelButton>
Expand All @@ -83,6 +95,7 @@ export default class ProfileSubscription extends Component<Signature> {
</div>
</FieldContainer>
</WithSubscriptionData>

<style scoped>
.profile-field :deep(.invalid) {
box-shadow: none;
Expand Down Expand Up @@ -157,6 +170,4 @@ export default class ProfileSubscription extends Component<Signature> {
}
</style>
</template>

@service private declare billingService: BillingService;
}
6 changes: 4 additions & 2 deletions packages/host/app/services/billing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { trackedFunction } from 'ember-resources/util/function';

import {
SupportedMimeType,
encodeToAlphanumeric,
encodeWebSafeBase64,
} from '@cardstack/runtime-common';

import NetworkService from './network';
Expand Down Expand Up @@ -93,11 +93,13 @@ export default class BillingService extends Service {
};
}[];
};

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',
Expand All @@ -116,7 +118,7 @@ export default class BillingService extends Service {
// 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);
const clientReferenceId = encodeWebSafeBase64(matrixUserId);
return `${this.freePlanPaymentLink?.url}?client_reference_id=${clientReferenceId}`;
}

Expand Down
27 changes: 23 additions & 4 deletions packages/host/tests/acceptance/operator-mode-acceptance-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions packages/matrix/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,11 +620,11 @@ export async function setupPayment(
// mock trigger stripe webhook 'invoice.payment_succeeded'
await realmServer.executeSQL(
`INSERT INTO subscriptions (
user_id,
plan_id,
started_at,
user_id,
plan_id,
started_at,
ended_at,
status,
status,
stripe_subscription_id
) VALUES (
'${userId}',
Expand All @@ -643,8 +643,8 @@ export async function setupPayment(

await realmServer.executeSQL(
`INSERT INTO subscription_cycles (
subscription_id,
period_start,
subscription_id,
period_start,
period_end
) VALUES (
'${subscriptionUUID}',
Expand Down Expand Up @@ -681,7 +681,7 @@ export async function setupUserSubscribed(
username: string,
realmServer: IsolatedRealmServer,
) {
const matrixUserId = encodeToAlphanumeric(username);
const matrixUserId = encodeWebSafeBase64(username);
await setupUser(username, realmServer);
await setupPayment(matrixUserId, realmServer);
}
Expand Down Expand Up @@ -753,7 +753,7 @@ export async function waitUntil<T>(
throw new Error('Timeout waiting for condition');
}

export function encodeToAlphanumeric(string: string) {
export function encodeWebSafeBase64(string: string) {
return Buffer.from(string)
.toString('base64')
.replace(/\+/g, '-')
Expand Down
6 changes: 3 additions & 3 deletions packages/matrix/tests/registration-with-token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
enterWorkspace,
showAllCards,
setupUser,
encodeToAlphanumeric,
encodeWebSafeBase64,
} from '../helpers';
import { registerUser, createRegistrationToken } from '../docker/synapse';

Expand Down Expand Up @@ -115,7 +115,7 @@ test.describe('User Registration w/ Token - isolated realm server', () => {
);

// base 64 encode the matrix user id
const matrixUserId = encodeToAlphanumeric('@user1:localhost');
const matrixUserId = encodeWebSafeBase64('@user1:localhost');
await setupPayment(matrixUserId, realmServer, page);
await assertLoggedIn(page, {
email: 'user1@example.com',
Expand Down Expand Up @@ -205,7 +205,7 @@ test.describe('User Registration w/ Token - isolated realm server', () => {
page.locator('[data-test-setup-payment-message]'),
).toContainText('Setup your payment method now to enjoy Boxel');

const user2MatrixUserId = encodeToAlphanumeric('@user2:localhost');
const user2MatrixUserId = encodeWebSafeBase64('@user2:localhost');

await setupPayment(user2MatrixUserId, realmServer, page);

Expand Down
4 changes: 2 additions & 2 deletions packages/matrix/tests/registration-without-token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
assertLoggedIn,
setupPayment,
registerRealmUsers,
encodeToAlphanumeric,
encodeWebSafeBase64,
} from '../helpers';

test.describe('User Registration w/o Token', () => {
Expand Down Expand Up @@ -69,7 +69,7 @@ test.describe('User Registration w/o Token', () => {
);

// base 64 encode the matrix user id
const matrixUserId = encodeToAlphanumeric('@user1:localhost');
const matrixUserId = encodeWebSafeBase64('@user1:localhost');

await setupPayment(matrixUserId, realmServer, page);
await assertLoggedIn(page);
Expand Down
27 changes: 23 additions & 4 deletions packages/realm-server/tests/billing-test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { encodeToAlphanumeric, param, query } from '@cardstack/runtime-common';
import {
decodeWebSafeBase64,
encodeWebSafeBase64,
param,
query,
} from '@cardstack/runtime-common';
import { module, test } from 'qunit';
import {
fetchSubscriptionsByUserId,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -741,7 +759,7 @@ module('billing', function (hooks) {
object: {
id: 'cs_test_1234567890',
object: 'checkout.session',
client_reference_id: encodeToAlphanumeric(matrixUserId),
client_reference_id: encodeWebSafeBase64(matrixUserId),
customer: 'cus_123',
metadata: {},
},
Expand Down Expand Up @@ -787,7 +805,7 @@ module('billing', function (hooks) {
object: {
id: 'cs_test_1234567890',
object: 'checkout.session',
client_reference_id: encodeToAlphanumeric(matrixUserId),
client_reference_id: encodeWebSafeBase64(matrixUserId),
customer: 'cus_123',
metadata: {},
},
Expand Down Expand Up @@ -843,7 +861,8 @@ module('billing', function (hooks) {
object: {
id: 'cs_test_1234567890',
object: 'checkout.session',
customer: 'cus_123',
customer: null,
client_reference_id: encodeWebSafeBase64(matrixUserId),
metadata: {
credit_reload_amount: '25000',
},
Expand Down
4 changes: 2 additions & 2 deletions packages/realm-server/tests/realm-server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
type SingleCardDocument,
type QueuePublisher,
type QueueRunner,
encodeToAlphanumeric,
encodeWebSafeBase64,
} from '@cardstack/runtime-common';
import { stringify } from 'qs';
import { v4 as uuidv4 } from 'uuid';
Expand Down Expand Up @@ -4508,7 +4508,7 @@ module('Realm Server', function (hooks) {
object: {
id: 'cs_test_1234567890',
object: 'checkout.session',
client_reference_id: encodeToAlphanumeric(userId),
client_reference_id: encodeWebSafeBase64(userId),
customer: 'cus_123',
metadata: {},
},
Expand Down
Loading

0 comments on commit 9d1208f

Please sign in to comment.