Skip to content

Commit

Permalink
Integrate Stripe billing portal
Browse files Browse the repository at this point in the history
  • Loading branch information
oscartbeaumont committed Jan 31, 2024
1 parent 9f6275a commit e024a69
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 3 deletions.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ PROD_URL=http://localhost:3000
MSFT_CLIENT_ID=
MSFT_CLIENT_SECRET=
MSFT_ADMIN_TENANT=
DATABASE_URL='mysql://todo:[email protected]/todo?ssl={"rejectUnauthorized":true}'
DATABASE_URL='mysql://todo:[email protected]/todo?ssl={"rejectUnauthorized":true}'
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
4 changes: 3 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@
"base58": "^2.0.1",
"drizzle-orm": "^0.29.3",
"hono": "^3.12.8",
"stripe": "^14.14.0",
"superjson": "^2.2.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0"
"@microsoft/microsoft-graph-types": "^2.40.0",
"@types/stripe": "^8.0.417"
}
}
2 changes: 2 additions & 0 deletions api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const tenants = mysqlTable("tenant", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
description: varchar("description", { length: 256 }),
billingEmail: varchar("billingEmail", { length: 256 }),
stripeCustomerId: varchar("stripeCustomerId", { length: 256 }),
owner_id: int("owner_id")
.references(() => accounts.id)
.notNull(),
Expand Down
2 changes: 2 additions & 0 deletions api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const env = createEnv({
// Get these values from the output of the Cloudformation template
AWS_ACCESS_KEY_ID: optional_in_dev(z.string()),
AWS_SECRET_ACCESS_KEY: optional_in_dev(z.string()),
STRIPE_PUBLISHABLE_KEY: z.string(),
STRIPE_SECRET_KEY: z.string(),
},
clientPrefix: "VITE_",
client: {},
Expand Down
43 changes: 43 additions & 0 deletions api/src/routers/tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
users,
} from "../db";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import { env } from "../env";

export const tenantRouter = createTRPCRouter({
create: authedProcedure
Expand Down Expand Up @@ -83,4 +85,45 @@ export const tenantRouter = createTRPCRouter({
id: encodeId("account", row.id),
}));
}),

stripePortalUrl: tenantProcedure.mutation(async ({ ctx }) => {
const tenant = (
await db
.select({
name: tenants.name,
billingEmail: tenants.billingEmail,
stripeCustomerId: tenants.stripeCustomerId,
})
.from(tenants)
.where(eq(tenants.id, ctx.tenantId))
)?.[0];
if (!tenant) throw new Error("Tenant not found!"); // TODO: Proper error code which the frontend knows how to handle

let customerId: string;
if (!tenant.stripeCustomerId) {
const customer = await stripe.customers.create({
name: tenant.name,
email: tenant.billingEmail || undefined,
});

await db
.update(tenants)
.set({ stripeCustomerId: customer.id })
.where(eq(tenants.id, ctx.tenantId));

customerId = customer.id;
} else {
customerId = tenant.stripeCustomerId;
}

const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${env.PROD_URL}/${encodeId(
"tenant",
ctx.tenantId
)}/settings`,
});

return session.url;
}),
});
4 changes: 4 additions & 0 deletions api/src/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import Stripe from "stripe";
import { env } from "./env";

export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
18 changes: 17 additions & 1 deletion forge/src/routes/(dash)/[tenant]/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ function MigrateCard() {
}

function BillingCard() {
const stripePortalUrl = trpc.tenant.stripePortalUrl.useMutation(() => ({
onSuccess: async (url) => {
// @ts-expect-error
window.location = url;

// Make sure the button is disabled until the user is in the new tab
await new Promise((resolve) => setTimeout(resolve, 500));
},
}));

return (
<Card class="w-[350px]">
<CardHeader>
Expand All @@ -210,7 +220,13 @@ function BillingCard() {
</CardHeader>
<CardContent class="flex flex-col space-y-2">
<p>Devices: 0</p>
<Button disabled class="w-full">
{/* TODO: How much is owed and when it's due */}

<Button
class="w-full"
onClick={() => stripePortalUrl.mutate()}
disabled={stripePortalUrl.isPending}
>
Go to Stipe
</Button>
</CardContent>
Expand Down
87 changes: 87 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit e024a69

Please sign in to comment.