diff --git a/README.md b/README.md index 7c9b4aeb90..1fa03b6e38 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ For a quickstart, see [here](./QUICKSTART.md) - this project uses [volta](https://volta.sh/) for Javascript toolchain version management. Make sure you have the latest verison of volta on your system and have define [the ENV var described here](https://docs.volta.sh/advanced/pnpm). - this project uses [pnpm](https://pnpm.io/) for package management. run `pnpm install` to install the project dependencies first. - this project uses [docker](https://docker.com). Make sure to install docker on your system. -- this project uses [stripe-cli](https://stripe.com/docs/stripe-cli) to test payment flows. Make sure to install stripe-cli on your system. - Ensure that node_modules/.bin is in your path. e.g. include `export PATH="./node_modules/.bin:$PATH"` in your .zshrc ## Orientation @@ -234,10 +233,10 @@ In order to run the boxel-motion demo app: There is some pre-setup needed to enable free plan on development account: -1. Use stripe cli to listen for the webhooks that Stripe sends to the realm server +1. Go to `packages/realm-server` and run stripe script to listen for the webhooks that Stripe sends to the realm server ``` -stripe listen --forward-to localhost:4201/_stripe-webhook --api-key sk_test_api_key_from_the_sandbox_account +pnpm stripe listen --forward-to localhost:4201/_stripe-webhook --api-key sk_test_api_key_from_the_sandbox_account ``` 2. You will get webhook signing secret from stripe cli after Step 1 is done diff --git a/packages/billing/billing-queries.ts b/packages/billing/billing-queries.ts index 07ab2076ea..b3b715c1af 100644 --- a/packages/billing/billing-queries.ts +++ b/packages/billing/billing-queries.ts @@ -108,18 +108,30 @@ export async function getPlanByStripeId( export async function updateUserStripeCustomerId( dbAdapter: DBAdapter, - userId: string, + matrixUserId: string, stripeCustomerId: string, ) { - let { valueExpressions, nameExpressions } = asExpressions({ - stripe_customer_id: stripeCustomerId, - }); + let user = await getUserByMatrixUserId(dbAdapter, matrixUserId); - await query(dbAdapter, [ - ...update('users', nameExpressions, valueExpressions), - ` WHERE matrix_user_id = `, - param(userId), - ]); + if (!user) { + // This means there is no user in our db yet, which is a case for matrix users that signed up before we + // introduced the users table and starded inserting users on realm creation. + // We can just create a new user in our db with matrix user id and stripe customer id. + let { valueExpressions, nameExpressions } = asExpressions({ + matrix_user_id: matrixUserId, + stripe_customer_id: stripeCustomerId, + }); + await query(dbAdapter, insert('users', nameExpressions, valueExpressions)); + } else { + let { valueExpressions, nameExpressions } = asExpressions({ + stripe_customer_id: stripeCustomerId, + }); + await query(dbAdapter, [ + ...update('users', nameExpressions, valueExpressions), + ` WHERE matrix_user_id = `, + param(matrixUserId), + ]); + } } export async function getUserByStripeId( diff --git a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts index 6eaee7b948..38858cca5c 100644 --- a/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts +++ b/packages/billing/stripe-webhook-handlers/checkout-session-completed.ts @@ -34,14 +34,15 @@ export async function handleCheckoutSessionCompleted( 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 base64UserId = matrixUserName.replace(/-/g, '+').replace(/_/g, '/'); - const decodedMatrixUserName = Buffer.from( - base64UserId, - 'base64', - ).toString('utf8'); + const base64MatrixUserId = matrixUserName + .replace(/-/g, '+') + .replace(/_/g, '/'); + const matrixUserId = Buffer.from(base64MatrixUserId, 'base64').toString( + 'utf8', + ); await updateUserStripeCustomerId( dbAdapter, - decodedMatrixUserName, + matrixUserId, stripeCustomerId, ); } diff --git a/packages/host/app/services/billing-service.ts b/packages/host/app/services/billing-service.ts index 4a489df120..28d046338e 100644 --- a/packages/host/app/services/billing-service.ts +++ b/packages/host/app/services/billing-service.ts @@ -5,7 +5,10 @@ import { tracked } from '@glimmer/tracking'; import { dropTask } from 'ember-concurrency'; -import { SupportedMimeType } from '@cardstack/runtime-common'; +import { + SupportedMimeType, + encodeToAlphanumeric, +} from '@cardstack/runtime-common'; import ENV from '@cardstack/host/config/environment'; @@ -43,20 +46,12 @@ export default class BillingService extends Service { this._subscriptionData = null; } - encodeToAlphanumeric(matrixUserId: string) { - return Buffer.from(matrixUserId) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - } - 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 = this.encodeToAlphanumeric(matrixUserId); + const clientReferenceId = encodeToAlphanumeric(matrixUserId); return `${stripePaymentLink}?client_reference_id=${clientReferenceId}`; } @@ -87,29 +82,31 @@ export default class BillingService extends Service { Authorization: `Bearer ${await this.getToken()}`, }, }); - if (response.status !== 200) { - throw new Error( + + 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 { + console.error( `Failed to fetch user for realm server ${this.url.origin}: ${response.status}`, ); } - 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/realm-server/handlers/handle-stripe-links.ts b/packages/realm-server/handlers/handle-stripe-links.ts new file mode 100644 index 0000000000..79d458ca3e --- /dev/null +++ b/packages/realm-server/handlers/handle-stripe-links.ts @@ -0,0 +1,124 @@ +import Koa from 'koa'; +import { setContextResponse } from '../middleware'; + +import Stripe from 'stripe'; +import { SupportedMimeType } from '@cardstack/runtime-common'; + +type CustomerPortalLink = { + type: 'customer-portal-link'; + id: 'customer-portal-link'; + attributes: { + url: string; + }; +}; + +type FreePlanPaymentLink = { + type: 'free-plan-payment-link'; + id: 'free-plan-payment-link'; + attributes: { + url: string; + }; +}; + +type ExtraCreditsPaymentLink = { + type: 'extra-credits-payment-link'; + id: `extra-credits-payment-link-${number}`; + attributes: { + url: string; + metadata: { + creditReloadAmount: number; + }; + }; +}; + +type PaymentLink = + | CustomerPortalLink + | FreePlanPaymentLink + | ExtraCreditsPaymentLink; + +interface APIResponse { + data: PaymentLink[]; +} + +export default function handleStripeLinksRequest(): ( + ctxt: Koa.Context, + next: Koa.Next, +) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + let stripe = new Stripe(process.env.STRIPE_API_KEY!); + + let configurations = await stripe.billingPortal.configurations.list(); + if (configurations.data.length !== 1) { + throw new Error('Expected exactly one billing portal configuration'); + } + + let configuration = configurations.data[0]; + let customerPortalLink = configuration.login_page.url; + + if (!customerPortalLink) { + throw new Error( + 'Expected customer portal link in the billing portal configuration', + ); + } + + let paymentLinks = await stripe.paymentLinks.list({ + active: true, + }); + + let freePlanPaymentLink = paymentLinks.data.find( + (link) => link.metadata?.free_plan === 'true', + ); + + if (!freePlanPaymentLink) { + throw new Error( + 'Expected free plan payment link with metadata.free_plan=true but none found', + ); + } + + let creditTopUpPaymentLinks = paymentLinks.data.filter( + (link) => !!link.metadata?.credit_reload_amount, + ); + + if (creditTopUpPaymentLinks.length !== 3) { + throw new Error( + `Expected exactly three credit top up payment links with metadata.credit_reload_amount defined but ${creditTopUpPaymentLinks.length} found`, + ); + } + + let response = { + data: [ + { + type: 'customer-portal-link', + id: 'customer-portal-link', + attributes: { + url: customerPortalLink, + }, + }, + { + type: 'free-plan-payment-link', + id: 'free-plan-payment-link', + attributes: { + url: freePlanPaymentLink.url, + }, + }, + ...creditTopUpPaymentLinks.map((link, index) => ({ + type: 'extra-credits-payment-link', + id: `extra-credits-payment-link-${index}`, + attributes: { + url: link.url, + metadata: { + creditReloadAmount: parseInt(link.metadata.credit_reload_amount), + }, + }, + })), + ], + } as APIResponse; + + return setContextResponse( + ctxt, + new Response(JSON.stringify(response), { + headers: { 'content-type': SupportedMimeType.JSONAPI }, + }), + ); + }; +} diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index 1358b4c3f6..1c19c1b13a 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -96,7 +96,8 @@ "lint:js:fix": "eslint . --fix", "lint:glint": "glint", "full-reset": "./scripts/full-reset.sh", - "sync-stripe-products": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly scripts/sync-stripe-products.ts" + "sync-stripe-products": "NODE_NO_WARNINGS=1 PGDATABASE=boxel PGPORT=5435 ts-node --transpileOnly scripts/sync-stripe-products.ts", + "stripe": "docker run --rm -it stripe/stripe-cli:latest" }, "volta": { "extends": "../../package.json" diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index 4623b68ed0..f62c2f8699 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -8,6 +8,7 @@ import handleFetchUserRequest from './handlers/handle-fetch-user'; import handleStripeWebhookRequest from './handlers/handle-stripe-webhook'; import { healthCheck, jwtMiddleware, livenessCheck } from './middleware'; import Koa from 'koa'; +import handleStripeLinksRequest from './handlers/handle-stripe-links'; export type CreateRoutesArgs = { dbAdapter: DBAdapter; @@ -50,6 +51,7 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.secretSeed), handleFetchUserRequest(args), ); + router.get('/_stripe-links', handleStripeLinksRequest()); return router.routes(); } diff --git a/packages/realm-server/tests/billing-test.ts b/packages/realm-server/tests/billing-test.ts index 60385f7998..9c1b457771 100644 --- a/packages/realm-server/tests/billing-test.ts +++ b/packages/realm-server/tests/billing-test.ts @@ -1,4 +1,4 @@ -import { param, query } from '@cardstack/runtime-common'; +import { encodeToAlphanumeric, param, query } from '@cardstack/runtime-common'; import { module, test } from 'qunit'; import { fetchSubscriptionsByUserId, @@ -726,97 +726,145 @@ module('billing', function (hooks) { }); }); - module('checkout session completed', function (hooks) { + module('checkout session completed', function () { let user: User; - - hooks.beforeEach(async function () { - user = await insertUser(dbAdapter, 'testuser', 'cus_123'); - }); - - test('update user stripe customer id when checkout session completed', async function (assert) { - let stripeCheckoutSessionCompletedEvent = { - id: 'evt_1234567890', - object: 'event', - data: { - object: { - id: 'cs_test_1234567890', - object: 'checkout.session', - client_reference_id: 'testuser', - customer: 'cus_123', - metadata: {}, - }, - }, - type: 'checkout.session.completed', - } as StripeCheckoutSessionCompletedWebhookEvent; - - await handleCheckoutSessionCompleted( - dbAdapter, - stripeCheckoutSessionCompletedEvent, - ); - - let stripeEvents = await fetchStripeEvents(dbAdapter); - assert.strictEqual(stripeEvents.length, 1); - assert.strictEqual( - stripeEvents[0].stripe_event_id, - stripeCheckoutSessionCompletedEvent.id, - ); - - const updatedUser = await fetchUserByStripeCustomerId( - dbAdapter, - 'cus_123', - ); - assert.strictEqual(updatedUser.length, 1); - assert.strictEqual(updatedUser[0].stripe_customer_id, 'cus_123'); - assert.strictEqual(updatedUser[0].matrix_user_id, 'testuser'); - }); - - test('add extra credits to user ledger when checkout session completed', async function (assert) { - let creatorPlan = await insertPlan( - dbAdapter, - 'Creator', - 12, - 2500, - 'prod_creator', - ); - let subscription = await insertSubscription(dbAdapter, { - user_id: user.id, - plan_id: creatorPlan.id, - started_at: 1, - status: 'active', - stripe_subscription_id: 'sub_1234567890', - }); - await insertSubscriptionCycle(dbAdapter, { - subscriptionId: subscription.id, - periodStart: 1, - periodEnd: 2, - }); - let stripeCheckoutSessionCompletedEvent = { - id: 'evt_1234567890', - object: 'event', - data: { - object: { - id: 'cs_test_1234567890', - object: 'checkout.session', - customer: 'cus_123', - metadata: { - credit_reload_amount: '25000', + let matrixUserId = '@pepe:cardstack.com'; + + module( + 'without entry in users table before webhook arrival (legacy users registered prior to users table introduction)', + function () { + test('updates user stripe customer id on checkout session completed', async function (assert) { + let stripeCheckoutSessionCompletedEvent = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + client_reference_id: encodeToAlphanumeric(matrixUserId), + customer: 'cus_123', + metadata: {}, + }, }, - }, - }, - type: 'checkout.session.completed', - } as StripeCheckoutSessionCompletedWebhookEvent; + type: 'checkout.session.completed', + } as StripeCheckoutSessionCompletedWebhookEvent; + + await handleCheckoutSessionCompleted( + dbAdapter, + stripeCheckoutSessionCompletedEvent, + ); + + let stripeEvents = await fetchStripeEvents(dbAdapter); + assert.strictEqual(stripeEvents.length, 1); + assert.strictEqual( + stripeEvents[0].stripe_event_id, + stripeCheckoutSessionCompletedEvent.id, + ); + + const updatedUser = await fetchUserByStripeCustomerId( + dbAdapter, + 'cus_123', + ); + assert.strictEqual(updatedUser.length, 1); + assert.strictEqual(updatedUser[0].stripe_customer_id, 'cus_123'); + assert.strictEqual(updatedUser[0].matrix_user_id, matrixUserId); + }); + }, + ); + + module( + 'with entry in users table before webhook arrival', + function (hooks) { + hooks.beforeEach(async function () { + user = await insertUser(dbAdapter, matrixUserId, 'cus_123'); + }); - await handleCheckoutSessionCompleted( - dbAdapter, - stripeCheckoutSessionCompletedEvent, - ); + test('updates user stripe customer id on checkout session completed', async function (assert) { + let stripeCheckoutSessionCompletedEvent = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + client_reference_id: encodeToAlphanumeric(matrixUserId), + customer: 'cus_123', + metadata: {}, + }, + }, + type: 'checkout.session.completed', + } as StripeCheckoutSessionCompletedWebhookEvent; + + await handleCheckoutSessionCompleted( + dbAdapter, + stripeCheckoutSessionCompletedEvent, + ); + + let stripeEvents = await fetchStripeEvents(dbAdapter); + assert.strictEqual(stripeEvents.length, 1); + assert.strictEqual( + stripeEvents[0].stripe_event_id, + stripeCheckoutSessionCompletedEvent.id, + ); + + const updatedUser = await fetchUserByStripeCustomerId( + dbAdapter, + 'cus_123', + ); + assert.strictEqual(updatedUser.length, 1); + assert.strictEqual(updatedUser[0].stripe_customer_id, 'cus_123'); + assert.strictEqual(updatedUser[0].matrix_user_id, matrixUserId); + }); - let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { - userId: user.id, - creditType: 'extra_credit', - }); - assert.strictEqual(availableExtraCredits, 25000); - }); + test('add extra credits to user ledger when checkout session completed', async function (assert) { + let creatorPlan = await insertPlan( + dbAdapter, + 'Creator', + 12, + 2500, + 'prod_creator', + ); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: creatorPlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + let stripeCheckoutSessionCompletedEvent = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + customer: 'cus_123', + metadata: { + credit_reload_amount: '25000', + }, + }, + }, + type: 'checkout.session.completed', + } as StripeCheckoutSessionCompletedWebhookEvent; + + await handleCheckoutSessionCompleted( + dbAdapter, + stripeCheckoutSessionCompletedEvent, + ); + + let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: 'extra_credit', + }); + assert.strictEqual(availableExtraCredits, 25000); + }); + }, + ); }); module('AI usage tracking', function (hooks) { diff --git a/packages/realm-server/tests/realm-server-test.ts b/packages/realm-server/tests/realm-server-test.ts index 5d75652821..9864c7db2a 100644 --- a/packages/realm-server/tests/realm-server-test.ts +++ b/packages/realm-server/tests/realm-server-test.ts @@ -31,6 +31,7 @@ import { type SingleCardDocument, type QueuePublisher, type QueueRunner, + encodeToAlphanumeric, } from '@cardstack/runtime-common'; import { stringify } from 'qs'; import { v4 as uuidv4 } from 'uuid'; @@ -4506,7 +4507,7 @@ module('Realm Server', function (hooks) { object: { id: 'cs_test_1234567890', object: 'checkout.session', - client_reference_id: userId, + client_reference_id: encodeToAlphanumeric(userId), customer: 'cus_123', metadata: {}, }, diff --git a/packages/runtime-common/utils.ts b/packages/runtime-common/utils.ts index a671d2e9e0..27824f0200 100644 --- a/packages/runtime-common/utils.ts +++ b/packages/runtime-common/utils.ts @@ -19,3 +19,11 @@ export async function retry( return null; } + +export function encodeToAlphanumeric(matrixUserId: string) { + return Buffer.from(matrixUserId) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +}