Skip to content

Commit

Permalink
Use stripe fecth price endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
FadhlanR committed Nov 18, 2024
1 parent d47bbb9 commit e4efaea
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 70 deletions.
64 changes: 23 additions & 41 deletions packages/billing/billing-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,24 +106,6 @@ export async function getPlanByStripeId(
} as Plan;
}

export async function getPlan(dbAdapter: DBAdapter, id: string): Promise<Plan> {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Plan | null> {
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;
}

Expand Down
15 changes: 15 additions & 0 deletions packages/billing/stripe-webhook-handlers/stripe.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 26 additions & 23 deletions packages/billing/stripe-webhook-handlers/subscription-deleted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
}
10 changes: 5 additions & 5 deletions packages/realm-server/handlers/handle-fetch-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
} from '../middleware';
import { RealmServerTokenClaim } from '../utils/jwt';
import {
getMostRecentSubscription,
getCurrentActiveSubscription,
getMostRecentSubscriptionCycle,
getPlan,
getPlanById,
getUserByMatrixUserId,
Plan,
SubscriptionCycle,
Expand Down Expand Up @@ -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),
]);
}

Expand Down
114 changes: 113 additions & 1 deletion packages/realm-server/tests/realm-server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/');
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
);
});
});
});
Expand Down

0 comments on commit e4efaea

Please sign in to comment.