Skip to content

Commit

Permalink
Merge pull request #246 from giselles-ai/send-user-seat-meter-event
Browse files Browse the repository at this point in the history
Send user seat meter event
  • Loading branch information
shige authored Dec 18, 2024
2 parents bd4dfb7 + b847457 commit eda9b57
Show file tree
Hide file tree
Showing 9 changed files with 2,234 additions and 15 deletions.
32 changes: 32 additions & 0 deletions app/webhooks/stripe/handle-subscription-cycle-invoice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { toUTCDate } from "@/lib/date";
import { reportUserSeatUsage } from "@/services/usage-based-billing";
import type Stripe from "stripe";
import invariant from "tiny-invariant";

export async function handleSubscriptionCycleInvoice(invoice: Stripe.Invoice) {
if (invoice.status !== "draft") {
/**
* User seat usage must be reported while the invoice is in draft status.
* If this error occurs in production, consider adjusting the invoice finalization grace period:
* https://docs.stripe.com/billing/subscriptions/usage-based/configure-grace-period
*
* Note: Extending the grace period will delay the invoice delivery to customers.
* To minimize billing delays, we should consider manually calling finalizeInvoice()
* immediately after reporting the usage.
*/
throw new Error("Invoice is not in draft status");
}

const subscription = invoice.subscription;
invariant(subscription, "Invoice is missing a subscription ID");
const subscriptionId =
typeof subscription === "string" ? subscription : subscription.id;

const customer = invoice.customer;
invariant(customer, "Invoice is missing a customer ID");
const customerId = typeof customer === "string" ? customer : customer.id;
const periodEnd = new Date(invoice.period_end * 1000);
const periodEndUTC = toUTCDate(periodEnd);

await reportUserSeatUsage(subscriptionId, customerId, periodEndUTC);
}
37 changes: 22 additions & 15 deletions app/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { stripe } from "@/services/external/stripe";
import { upsertSubscription } from "@/services/external/stripe/actions/upsert-subscription";
import type Stripe from "stripe";
import { handleSubscriptionCycleInvoice } from "./handle-subscription-cycle-invoice";

const relevantEvents = new Set([
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.created",
]);

export async function POST(req: Request) {
Expand Down Expand Up @@ -35,20 +36,6 @@ export async function POST(req: Request) {

try {
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
if (
event.data.object.customer == null ||
typeof event.data.object.customer !== "string"
) {
throw new Error(
"The checkout session is missing a valid customer ID. Please check the session data.",
);
}
await upsertSubscription(event.data.object.id);
break;

case "checkout.session.completed":
if (event.data.object.mode !== "subscription") {
throw new Error("Unhandled relevant event!");
Expand All @@ -72,6 +59,26 @@ export async function POST(req: Request) {
await upsertSubscription(event.data.object.subscription);
break;

case "customer.subscription.updated":
case "customer.subscription.deleted":
if (
event.data.object.customer == null ||
typeof event.data.object.customer !== "string"
) {
throw new Error(
"The checkout session is missing a valid customer ID. Please check the session data.",
);
}
await upsertSubscription(event.data.object.id);
break;

case "invoice.created":
console.log(`🔔 Invoice created: ${event.data.object.id}`);
if (event.data.object.billing_reason === "subscription_cycle") {
await handleSubscriptionCycleInvoice(event.data.object);
}
break;

default:
throw new Error("Unhandled relevant event!");
}
Expand Down
19 changes: 19 additions & 0 deletions drizzle/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,22 @@ export const agentTimeUsageReports = pgTable(
stripeMeterEventIdIdx: index().on(table.stripeMeterEventId),
}),
);

export const userSeatUsageReports = pgTable(
"user_seat_usage_reports",
{
dbId: serial("db_id").primaryKey(),
teamDbId: integer("team_db_id")
.notNull()
.references(() => teams.dbId, { onDelete: "cascade" }),
// Keep snapshot for audit purposes
userDbIdList: integer("user_db_id_list").array().notNull(),
stripeMeterEventId: text("stripe_meter_event_id").notNull(),
timestamp: timestamp("created_at").defaultNow().notNull(),
},
(table) => ({
teamDbIdIdx: index().on(table.teamDbId),
timestampIdx: index().on(table.timestamp),
stripeMeterEventIdIdx: index().on(table.stripeMeterEventId),
}),
);
13 changes: 13 additions & 0 deletions lib/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function toUTCDate(date: Date): Date {
return new Date(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
),
);
}
17 changes: 17 additions & 0 deletions migrations/0018_greedy_starhawk.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS "user_seat_usage_reports" (
"db_id" serial PRIMARY KEY NOT NULL,
"team_db_id" integer NOT NULL,
"user_db_id_list" integer[] NOT NULL,
"stripe_meter_event_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "user_seat_usage_reports" ADD CONSTRAINT "user_seat_usage_reports_team_db_id_teams_db_id_fk" FOREIGN KEY ("team_db_id") REFERENCES "public"."teams"("db_id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_seat_usage_reports_team_db_id_index" ON "user_seat_usage_reports" USING btree ("team_db_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_seat_usage_reports_created_at_index" ON "user_seat_usage_reports" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_seat_usage_reports_stripe_meter_event_id_index" ON "user_seat_usage_reports" USING btree ("stripe_meter_event_id");
Loading

0 comments on commit eda9b57

Please sign in to comment.