Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"prettier.trailingComma": "all"
}
6 changes: 3 additions & 3 deletions infra/billing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { bus } from "./bus";
import { database } from "./planetscale";
import { assumable, secret } from "./secret";
import { allSecrets, assumable, secret } from "./secret";

const queue = new sst.aws.Queue("BillingQueue", {
fifo: true,
Expand All @@ -9,7 +9,7 @@ const queue = new sst.aws.Queue("BillingQueue", {

queue.subscribe(
{
link: [database, secret.StripeSecretKey],
link: [database, ...allSecrets],
handler: "packages/functions/src/billing/fetch-usage.handler",
permissions: [assumable],
timeout: "3 minutes",
Expand All @@ -27,6 +27,6 @@ new sst.aws.Cron("BillingCron", {
handler: "packages/functions/src/billing/cron.handler",
timeout: "900 seconds",
permissions: [assumable],
link: [bus, database, queue],
link: [bus, database, queue, ...allSecrets],
},
});
4 changes: 2 additions & 2 deletions infra/secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export const secret = {
StripeResourcesPriceID: new sst.Secret(
"StripeResourcesPriceID",
$app.stage === "production"
? "price_xyz123"
: "price_1QgxZcEAHP8a0ogpIT1qxKlV"
? "price_1QhwLAEAHP8a0ogpjRV91Yl8"
: "price_1Qi4QzEAHP8a0ogpDvPDu8Bm"
),
SlackClientID: new sst.Secret("SlackClientID"),
SlackClientSecret: new sst.Secret("SlackClientSecret"),
Expand Down
2 changes: 1 addition & 1 deletion infra/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ new sst.aws.StaticSite("Workspace", {
}),
},
environment: {
VITE_API_URL: backend.url,
VITE_API_URL: apiRouter.url,
VITE_AUTH_URL: authRouter.url,
VITE_STAGE: $app.stage,
VITE_CONNECT_URL: connectTemplateUrl,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"env": "env",
"prepare": "git config --local core.hooksPath .githooks",
"dev": "sst dev",
"sso": "aws sso login --sso-session=sst --no-browser --use-device-code",
"sso": "aws sso login --sso-session=sst --no-browser",
"build": "sst build",
"deploy": "sst deploy",
"remove": "sst remove",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/api/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const BillingRoute = new Hono()
mode: "subscription",
line_items: [
{
price: Resource.StripePriceID.value,
price: Resource.StripeResourcesPriceID.value,
},
],
customer: item.customerID,
Expand Down
23 changes: 11 additions & 12 deletions packages/backend/src/api/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ WebhookRoute.post("/stripe", async (c) => {
const body = stripe.webhooks.constructEvent(
await c.req.text(),
c.req.header("stripe-signature")!,
Resource.StripeWebhookSigningSecret.value,
Resource.StripeWebhookSigningSecret.value
);

console.log(body.type, body);
if (body.type === "customer.subscription.created") {
const { id: subscriptionID, customer, items } = body.data.object;
const item = await Billing.Stripe.fromCustomerID(customer as string);
if (!item) {
throw new Error("Workspace not found for customer");
}
if (item.subscriptionID) {
if (!item) throw new Error("Workspace not found for customer");
if (item.subscriptionID)
throw new Error("Workspace already has a subscription");
}
if (!items.data[0]) throw new Error("Subscription items is empty");
const subscriptionItemID = items.data[0].id;
const priceID = items.data[0].price.id;

await withActor(
{
Expand All @@ -33,14 +33,13 @@ WebhookRoute.post("/stripe", async (c) => {
},
},
async () => {
if (!items.data[0]) throw new Error("Subscription items is empty");

await Billing.Stripe.setSubscription({
subscriptionID,
subscriptionItemID: items.data[0].id,
subscriptionItemID,
priceID,
});
await Billing.updateGatingStatus();
},
}
);
} else if (body.type === "customer.subscription.updated") {
const { id: subscriptionID, customer, status } = body.data.object;
Expand Down Expand Up @@ -74,7 +73,7 @@ WebhookRoute.post("/stripe", async (c) => {
});
await Billing.updateGatingStatus();
}
},
}
);
} else if (body.type === "customer.subscription.deleted") {
const { id: subscriptionID } = body.data.object;
Expand All @@ -93,7 +92,7 @@ WebhookRoute.post("/stripe", async (c) => {
invoice: id,
customer,
created: new Date(created * 1000),
},
}
);
}

Expand Down
7 changes: 5 additions & 2 deletions packages/backend/src/replicache/dummy/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3285,8 +3285,11 @@ function stripe({ standing }: StripeProps): DummyData {
subscriptionID: "sub_123",
standing: standing || "good",
subscriptionItemID: "sub_item_123",
timeDeleted: null,
timeTrialEnded: null,
price: "resources",
time: {
created: DateTime.now().startOf("day").toISO()!,
updated: DateTime.now().startOf("day").toISO()!,
},
...timestamps,
};
}
Expand Down
34 changes: 1 addition & 33 deletions packages/core/src/app/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,11 @@ import {
import { createId } from "@paralleldrive/cuid2";
import { useWorkspace } from "../actor";
import { awsAccount } from "../aws/aws.sql";
import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, isNull, sql } from "drizzle-orm";
import { AWS } from "../aws";
import {
GetObjectCommand,
ListObjectsV2Command,
S3Client,
} from "@aws-sdk/client-s3";
import { Enrichers, Resource } from "./resource";
import { db } from "../drizzle";
import { createEvent } from "../event";
import { Replicache } from "../replicache";
import { issueSubscriber } from "../issue/issue.sql";
import { bus } from "sst/aws/bus";
import { Resource as SSTResource } from "sst";
import { State } from "../state";
export * as Stage from "./stage";

export const Events = {
Expand Down Expand Up @@ -154,28 +144,6 @@ export const put = zod(
}),
);

export const list = zod(
z.object({
cursor: z.string().min(1).optional(),
}),
({ cursor }) =>
useTransaction(async (tx) => {
const SIZE = 100000;
const items = await tx
.select()
.from(stage)
.where(cursor ? gt(stage.id, cursor) : undefined)
.limit(SIZE)
.orderBy(asc(stage.id))
.execute()
.then((rows) => rows);
return {
items,
cursor: items.length < SIZE ? undefined : items.at(-1)?.id,
};
}),
);

export type StageCredentials = Exclude<
Awaited<ReturnType<typeof assumeRole>>,
undefined
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/billing/billing.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "drizzle-orm/mysql-core";
import { timestamps, workspaceID, cuid } from "../util/sql";

export const Standing = ["good", "overdue"] as const;

export const usage = mysqlTable(
"usage",
{
Expand Down Expand Up @@ -37,7 +39,7 @@ export const stripeTable = mysqlTable(
length: 255,
}),
priceID: varchar("price_id", { length: 255 }),
standing: mysqlEnum("standing", ["good", "overdue"]),
standing: mysqlEnum("standing", Standing),
timeTrialEnded: timestamp("time_trial_ended", { mode: "string" }),
},
(table) => ({
Expand Down
90 changes: 46 additions & 44 deletions packages/core/src/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { createSelectSchema } from "drizzle-zod";
import { usage } from "./billing.sql";
import { z } from "zod";
import { zod } from "../util/zod";
import { createId } from "@paralleldrive/cuid2";
import { eq, and, between, sql } from "drizzle-orm";
import { useTransaction } from "../util/transaction";
import { useWorkspace } from "../actor";
import { workspace } from "../workspace/workspace.sql";
import { Stripe } from "./stripe";
import { DateTime } from "luxon";
import { Warning } from "../warning";
import { stateCountTable } from "../state/state.sql";
import { Resource } from "sst";
export * as Billing from "./index";
export { Stripe } from "./stripe";

Expand All @@ -20,31 +21,9 @@ export const Usage = createSelectSchema(usage, {
});
export type Usage = z.infer<typeof Usage>;

const FREE_INVOCATIONS = 1000000;
const FREE_RESOURCES = 200;

export const createUsage = zod(
Usage.pick({ stageID: true, day: true, invocations: true }),
(input) =>
useTransaction((tx) =>
tx
.insert(usage)
.values({
id: createId(),
workspaceID: useWorkspace(),
stageID: input.stageID,
day: input.day,
invocations: input.invocations,
})
.onDuplicateKeyUpdate({
set: {
invocations: input.invocations,
},
})
.execute(),
),
);

export const countByStartAndEndDay = zod(
export const countInvocationsByStartAndEndDay = zod(
z.object({
startDay: Usage.shape.day,
endDay: Usage.shape.day,
Expand All @@ -57,13 +36,36 @@ export const countByStartAndEndDay = zod(
.where(
and(
eq(usage.workspaceID, useWorkspace()),
between(usage.day, input.startDay, input.endDay),
),
between(usage.day, input.startDay, input.endDay)
)
)
.execute(),
.execute()
);
return rows.reduce((acc, usage) => acc + usage.invocations, 0);
},
}
);

export const countResourcesByMonth = zod(
z.object({
month: z.string().min(1),
}),
async (input) => {
return await useTransaction((tx) =>
tx
.select({
total: sql<number>`SUM(${stateCountTable.count})`,
})
.from(stateCountTable)
.where(
and(
eq(stateCountTable.workspaceID, useWorkspace()),
eq(stateCountTable.month, input.month)
)
)
.execute()
.then((x) => x[0]?.total ?? 0)
);
}
);

export const updateGatingStatus = zod(z.void(), async () => {
Expand All @@ -85,22 +87,22 @@ export const updateGatingStatus = zod(z.void(), async () => {
return false;
}

const warnings = await Warning.forType({
type: "permission_usage",
stageID: null,
});
if (warnings.length) return true;

// check usage
if (!customer?.subscriptionID) {
const startDate = DateTime.now().toUTC().startOf("day");
const invocations = await countByStartAndEndDay({
startDay: startDate.startOf("month").toSQLDate()!,
endDay: startDate.endOf("month").toSQLDate()!,
// note: only check for permission_usage warnings if the price is for invocations
if (customer?.priceID === Resource.StripeInvocationsPriceID.value) {
const warnings = await Warning.forType({
type: "permission_usage",
stageID: null,
});
if (invocations > FREE_INVOCATIONS) return true;
if (warnings.length) return true;
}
return false;

if (customer?.priceID === Resource.StripeResourcesPriceID.value)
return false;

const resources = await countResourcesByMonth({
month: DateTime.utc().startOf("month").toSQLDate()!,
});
return resources > FREE_RESOURCES;
}

const timeGated = (await isGated()) ? sql`NOW()` : null;
Expand All @@ -110,6 +112,6 @@ export const updateGatingStatus = zod(z.void(), async () => {
.update(workspace)
.set({ timeGated })
.where(eq(workspace.id, useWorkspace()))
.execute(),
.execute()
);
});
Loading