From e4efaeaa2fcce4fb2c56c22bec1a3a702f5835c0 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 18 Nov 2024 17:33:45 +0700 Subject: [PATCH] Use stripe fecth price endpoint --- packages/billing/billing-queries.ts | 64 ++++------ .../billing/stripe-webhook-handlers/stripe.ts | 15 +++ .../subscription-deleted.ts | 49 ++++---- .../handlers/handle-fetch-user.ts | 10 +- .../realm-server/tests/realm-server-test.ts | 114 +++++++++++++++++- 5 files changed, 182 insertions(+), 70 deletions(-) create mode 100644 packages/billing/stripe-webhook-handlers/stripe.ts diff --git a/packages/billing/billing-queries.ts b/packages/billing/billing-queries.ts index acc7e5e2ff..827778c217 100644 --- a/packages/billing/billing-queries.ts +++ b/packages/billing/billing-queries.ts @@ -106,24 +106,6 @@ export async function getPlanByStripeId( } as Plan; } -export async function getPlan(dbAdapter: DBAdapter, id: string): Promise { - let results = await query(dbAdapter, [ - `SELECT * FROM plans WHERE id = `, - param(id), - ]); - - if (results.length !== 1) { - throw new Error(`No plan found with id: ${id}`); - } - - return { - id: results[0].id, - name: results[0].name, - monthlyPrice: results[0].monthly_price, - creditsIncluded: results[0].credits_included, - } as Plan; -} - export async function updateUserStripeCustomerId( dbAdapter: DBAdapter, userId: string, @@ -356,29 +338,6 @@ export async function getCurrentActiveSubscription( } as Subscription; } -export async function getMostRecentSubscription( - dbAdapter: DBAdapter, - userId: string, -) { - let results = await query(dbAdapter, [ - `SELECT * FROM subscriptions WHERE user_id = `, - param(userId), - `ORDER BY started_at DESC`, - ]); - if (results.length === 0) { - return null; - } - - return { - id: results[0].id, - userId: results[0].user_id, - planId: results[0].plan_id, - startedAt: results[0].started_at, - status: results[0].status, - stripeSubscriptionId: results[0].stripe_subscription_id, - } as Subscription; -} - export async function getMostRecentSubscriptionCycle( dbAdapter: DBAdapter, subscriptionId: string, @@ -470,6 +429,29 @@ export async function getPlanById( name: results[0].name, monthlyPrice: results[0].monthly_price, creditsIncluded: results[0].credits_included, + stripePlanId: results[0].stripe_plan_id, + } as Plan; +} + +export async function getPlanByMonthlyPrice( + dbAdapter: DBAdapter, + monthlyPrice: number, +): Promise { + let results = await query(dbAdapter, [ + `SELECT * FROM plans WHERE monthly_price = `, + param(monthlyPrice), + ]); + + if (results.length <= 0) { + return null; + } + + return { + id: results[0].id, + name: results[0].name, + monthlyPrice: results[0].monthly_price, + creditsIncluded: results[0].credits_included, + stripePlanId: results[0].stripe_plan_id, } as Plan; } diff --git a/packages/billing/stripe-webhook-handlers/stripe.ts b/packages/billing/stripe-webhook-handlers/stripe.ts new file mode 100644 index 0000000000..47aa51240e --- /dev/null +++ b/packages/billing/stripe-webhook-handlers/stripe.ts @@ -0,0 +1,15 @@ +import Stripe from 'stripe'; + +let stripe: Stripe; +// Need to export this function so the stripe instance can be mocked in test +export function getStripe() { + if (!process.env.STRIPE_WEBHOOK_SECRET) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set'); + } + + if (!stripe) { + stripe = new Stripe(process.env.STRIPE_WEBHOOK_SECRET); + } + + return stripe; +} diff --git a/packages/billing/stripe-webhook-handlers/subscription-deleted.ts b/packages/billing/stripe-webhook-handlers/subscription-deleted.ts index a78c6afe59..c3826e9e06 100644 --- a/packages/billing/stripe-webhook-handlers/subscription-deleted.ts +++ b/packages/billing/stripe-webhook-handlers/subscription-deleted.ts @@ -8,26 +8,11 @@ import { sumUpCreditsLedger, addToCreditsLedger, getMostRecentSubscriptionCycle, + getPlanByMonthlyPrice, } from '../billing-queries'; import { PgAdapter, TransactionManager } from '@cardstack/postgres'; -import Stripe from 'stripe'; - - - -let stripe: Stripe; - -export function getStripe() { - if (!process.env.STRIPE_WEBHOOK_SECRET) { - throw new Error('STRIPE_WEBHOOK_SECRET is not set'); - } - - if (!stripe) { - stripe = new Stripe(process.env.STRIPE_WEBHOOK_SECRET); - } - - return stripe; -} +import { getStripe } from './stripe'; export async function handleSubscriptionDeleted( dbAdapter: DBAdapter, @@ -88,24 +73,42 @@ export async function handleSubscriptionDeleted( // This happens when the payment method fails for a couple of times and then Stripe subscription gets expired. if (newStatus === 'expired') { - await subcribeUserToFreePlan(event.data.object.customer); + await subcribeUserToFreePlan(dbAdapter, event.data.object.customer); } await markStripeEventAsProcessed(dbAdapter, event.id); }); } -async function subcribeUserToFreePlan(stripeCustomerId: string) { +async function subcribeUserToFreePlan( + dbAdapter: DBAdapter, + stripeCustomerId: string, +) { try { - await getStripe().subscriptions.create({ + let stripe = getStripe(); + let freePlan = await getPlanByMonthlyPrice(dbAdapter, 0); + if (!freePlan) { + console.error('Free plan is not found'); + return; + } + let prices = await stripe.prices.list({ + product: freePlan.stripePlanId, + active: true, + }); + if (!prices.data[0]) { + console.error('No price found for free plan'); + return; + } + + await stripe.subscriptions.create({ customer: stripeCustomerId, items: [ { - price: '0' - } + price: prices.data[0].id, + }, ], }); - } catch(err) { + } catch (err) { console.error(`Failed to subscribe user back to free plan, error:`, err); } } diff --git a/packages/realm-server/handlers/handle-fetch-user.ts b/packages/realm-server/handlers/handle-fetch-user.ts index e9a3bbc6c6..5ace6f9598 100644 --- a/packages/realm-server/handlers/handle-fetch-user.ts +++ b/packages/realm-server/handlers/handle-fetch-user.ts @@ -7,9 +7,9 @@ import { } from '../middleware'; import { RealmServerTokenClaim } from '../utils/jwt'; import { - getMostRecentSubscription, + getCurrentActiveSubscription, getMostRecentSubscriptionCycle, - getPlan, + getPlanById, getUserByMatrixUserId, Plan, SubscriptionCycle, @@ -86,16 +86,16 @@ export default function handleFetchUserRequest({ return; } - let mostRecentSubscription = await getMostRecentSubscription( + let mostRecentSubscription = await getCurrentActiveSubscription( dbAdapter, user.id, ); let currentSubscriptionCycle: SubscriptionCycle | null = null; - let plan: Plan | undefined = undefined; + let plan: Plan | null = null; if (mostRecentSubscription) { [currentSubscriptionCycle, plan] = await Promise.all([ getMostRecentSubscriptionCycle(dbAdapter, mostRecentSubscription.id), - getPlan(dbAdapter, mostRecentSubscription.planId), + getPlanById(dbAdapter, mostRecentSubscription.planId), ]); } diff --git a/packages/realm-server/tests/realm-server-test.ts b/packages/realm-server/tests/realm-server-test.ts index c77feae8d9..830f911ede 100644 --- a/packages/realm-server/tests/realm-server-test.ts +++ b/packages/realm-server/tests/realm-server-test.ts @@ -74,7 +74,7 @@ import { createJWT as createRealmServerJWT } from '../utils/jwt'; import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms'; import Stripe from 'stripe'; import sinon from 'sinon'; -import { getStripe } from '@cardstack/billing/stripe-webhook-handlers/subscription-deleted'; +import { getStripe } from '@cardstack/billing/stripe-webhook-handlers/stripe'; setGracefulCleanup(); const testRealmURL = new URL('http://127.0.0.1:4444/'); @@ -3827,15 +3827,18 @@ module('Realm Server', function (hooks) { module('stripe webhook handler', function (hooks) { let createSubscriptionStub: sinon.SinonStub; + let fetchPriceListStub: sinon.SinonStub; hooks.beforeEach(async function () { shimExternals(virtualNetwork); process.env.STRIPE_WEBHOOK_SECRET = 'stripe-webhook-secret'; let stripe = getStripe(); createSubscriptionStub = sinon.stub(stripe.subscriptions, 'create'); + fetchPriceListStub = sinon.stub(stripe.prices, 'list'); }); hooks.afterEach(async function () { createSubscriptionStub.restore(); + fetchPriceListStub.restore(); }); setupPermissionedRealm(hooks, { @@ -3988,6 +3991,43 @@ module('Realm Server', function (hooks) { return createSubscriptionResponse; }); + let fetchPriceListResponse = { + object: 'list', + data: [ + { + id: 'price_1QMRCxH9rBd1yAHRD4BXhAHW', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1731921923, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_REv3E69DbAPv4K', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + }, + ], + has_more: false, + url: '/v1/prices', + }; + fetchPriceListStub.resolves(fetchPriceListResponse); + let stripeSubscriptionDeletedEvent = { id: 'evt_sub_deleted_1', object: 'event', @@ -4099,6 +4139,42 @@ module('Realm Server', function (hooks) { createSubscriptionStub.throws({ message: 'Failed subscribing to free plan', }); + let fetchPriceListResponse = { + object: 'list', + data: [ + { + id: 'price_1QMRCxH9rBd1yAHRD4BXhAHW', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1731921923, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_REv3E69DbAPv4K', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + }, + ], + has_more: false, + url: '/v1/prices', + }; + fetchPriceListStub.resolves(fetchPriceListResponse); let stripeSubscriptionDeletedEvent = { id: 'evt_sub_deleted_1', @@ -4134,6 +4210,42 @@ module('Realm Server', function (hooks) { assert.strictEqual(subscriptions.length, 1); assert.strictEqual(subscriptions[0].status, 'expired'); assert.strictEqual(subscriptions[0].planId, creatorPlan.id); + + // ensures the subscription info is null, + // so the host can use that to redirect user to checkout free plan page + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, '@test_realm:localhost', [ + 'read', + 'write', + ])}`, + ); + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let json = response.body; + assert.deepEqual( + json, + { + data: { + type: 'user', + id: user.id, + attributes: { + matrixUserId: user.matrixUserId, + stripeCustomerId: user.stripeCustomerId, + creditsAvailableInPlanAllowance: null, + creditsIncludedInPlanAllowance: null, + extraCreditsAvailableInBalance: null, + }, + relationships: { + subscription: null, + }, + }, + included: null, + }, + '/_user response is correct', + ); }); }); });