Skip to content

Commit

Permalink
Merge branch 'main' into cs-7576-show-error-when-save-request-blocked…
Browse files Browse the repository at this point in the history
…-by-waf
  • Loading branch information
backspace committed Nov 28, 2024
2 parents d17abac + 8bfb625 commit eea968f
Show file tree
Hide file tree
Showing 10 changed files with 331 additions and 138 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions packages/billing/billing-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
57 changes: 27 additions & 30 deletions packages/host/app/services/billing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -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() {
Expand Down
124 changes: 124 additions & 0 deletions packages/realm-server/handlers/handle-stripe-links.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 },
}),
);
};
}
3 changes: 2 additions & 1 deletion packages/realm-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions packages/realm-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,7 @@ export function createRoutes(args: CreateRoutesArgs) {
jwtMiddleware(args.secretSeed),
handleFetchUserRequest(args),
);
router.get('/_stripe-links', handleStripeLinksRequest());

return router.routes();
}
Loading

0 comments on commit eea968f

Please sign in to comment.