From 0047639877fcd0acbf40703489af1a2e9a2798dc Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Jan 2025 12:55:02 +0900 Subject: [PATCH 1/7] feat(billing): Handle timestamp differently for test clock simulation Add STRIPE_TEST_CLOCK_ENABLED flag to control timestamp generation behavior: - In test mode: Use current time for simulation - In production: Use period end time minus buffer for accurate billing This change improves testing capabilities while maintaining proper production behavior for usage-based billing calculations. --- services/usage-based-billing/user-seat.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/services/usage-based-billing/user-seat.ts b/services/usage-based-billing/user-seat.ts index 4bb4c3f5..482d39ab 100644 --- a/services/usage-based-billing/user-seat.ts +++ b/services/usage-based-billing/user-seat.ts @@ -10,6 +10,7 @@ import { stripe } from "../external/stripe"; const USER_SEAT_METER_NAME = "user_seat"; const PERIOD_END_BUFFER_MS = 1000 * 5; // 5 seconds +const STRIPE_TEST_CLOCK_ENABLED = false; // Set to true in development to simulate test clock export async function reportUserSeatUsage( subscriptionId: string, @@ -21,8 +22,11 @@ export async function reportUserSeatUsage( const currentMemberCount = teamMembers.length; const meterEventId = createId(); - // Must be with the subscrtipion period, so we subtract a buffer - const timestamp = new Date(periodEndUTC.getTime() - PERIOD_END_BUFFER_MS); + // Handle timestamp differently for test clock simulation vs production + const timestamp = isTestClockSimulation() + ? new Date() // Use current time for simulation + : new Date(periodEndUTC.getTime() - PERIOD_END_BUFFER_MS); // Use period end time minus buffer for production + const stripeEvent = await stripe.v2.billing.meterEvents.create({ event_name: USER_SEAT_METER_NAME, payload: { @@ -42,6 +46,11 @@ export async function reportUserSeatUsage( ); } +// Check if we're running in test clock simulation mode +function isTestClockSimulation(): boolean { + return process.env.NODE_ENV === "development" && STRIPE_TEST_CLOCK_ENABLED; +} + async function saveUserSeatUsage( stripeMeterEventId: string, teamDbId: number, From 1bcb8849579751147805ce81992411c539e258fc Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Jan 2025 13:01:18 +0900 Subject: [PATCH 2/7] refactor(webhook): Simplify invoice creation handling for canceled subscriptions Remove redundant comments and consolidate invoice handling logic: - Remove TODO comment and old conditional structure - Replace with cleaner invoice validation check - Prepare for implementing proper subscription cancellation processing The changes streamline the webhook handler code while maintaining functionality for canceled subscription scenarios. --- app/webhooks/stripe/route.ts | 44 +++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/app/webhooks/stripe/route.ts b/app/webhooks/stripe/route.ts index 9818a656..9da26fbc 100644 --- a/app/webhooks/stripe/route.ts +++ b/app/webhooks/stripe/route.ts @@ -84,31 +84,43 @@ export async function POST(req: Request) { await handleSubscriptionCancellation(event.data.object); break; - case "invoice.created": + case "invoice.created": { console.log(`🔔 Invoice created: ${event.data.object.id}`); - // TODO: Skip for now - will be handled when implementing subscription cancellation invoice processing - if ( - event.data.object.subscription && - typeof event.data.object.subscription === "string" - ) { - const subscriptionId = event.data.object.subscription; - const subscription = - await stripe.subscriptions.retrieve(subscriptionId); - - if (subscription.status === "canceled") { - console.log( - "Skipping processing for canceled subscription invoice: ", - subscriptionId, - ); - break; + const invoice = event.data.object; + + if (!invoice.subscription || typeof invoice.subscription !== "string") { + throw new Error( + "Invoice is missing a subscription ID. Please check the invoice data.", + ); + } + + const subscription = await stripe.subscriptions.retrieve( + invoice.subscription, + ); + + if (subscription.status === "canceled") { + try { + await stripe.invoices.finalizeInvoice(invoice.id); + } catch (error) { + console.error(`Error finalizing invoice ${invoice.id}:`, error); + throw new Error("Failed to finalize invoice."); + } + + try { + await stripe.invoices.pay(invoice.id); + } catch (error) { + console.error(`Error paying invoice ${invoice.id}:`, error); + throw new Error("Failed to pay invoice."); } } + // TODO: This block will be removed in the other issue. if (event.data.object.billing_reason === "subscription_cycle") { await handleSubscriptionCycleInvoice(event.data.object); } break; + } default: throw new Error("Unhandled relevant event!"); From edfa1aa0ca9c36cc593a8b1587931517c4d14f8d Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Jan 2025 13:15:53 +0900 Subject: [PATCH 3/7] refactor(webhook): Improve invoice creation handler with better error handling Split invoice processing into smaller, focused functions: - Extract finalizeAndPayInvoice into separate function - Add early return for non-canceled subscriptions - Improve code organization with clear responsibility separation This refactor enhances error handling and makes the code more maintainable by following single responsibility principle. --- .../stripe/handle-invoice-creation.ts | 30 ++++++++++++++++++ app/webhooks/stripe/route.ts | 31 ++----------------- 2 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 app/webhooks/stripe/handle-invoice-creation.ts diff --git a/app/webhooks/stripe/handle-invoice-creation.ts b/app/webhooks/stripe/handle-invoice-creation.ts new file mode 100644 index 00000000..b9d97fa9 --- /dev/null +++ b/app/webhooks/stripe/handle-invoice-creation.ts @@ -0,0 +1,30 @@ +import { stripe } from "@/services/external/stripe"; +import type Stripe from "stripe"; + +export async function handleInvoiceCreation(invoice: Stripe.Invoice) { + if (!invoice.subscription || typeof invoice.subscription !== "string") { + throw new Error( + "Invoice is missing a subscription ID. Please check the invoice data.", + ); + } + + const subscription = await stripe.subscriptions.retrieve( + invoice.subscription, + ); + + if (subscription.status !== "canceled") { + return; + } + + await finalizeAndPayInvoice(invoice.id); +} + +async function finalizeAndPayInvoice(invoiceId: string) { + try { + await stripe.invoices.finalizeInvoice(invoiceId); + await stripe.invoices.pay(invoiceId); + } catch (error) { + console.error(`Error processing invoice ${invoiceId}:`, error); + throw new Error("Failed to process invoice"); + } +} diff --git a/app/webhooks/stripe/route.ts b/app/webhooks/stripe/route.ts index 9da26fbc..02c9998b 100644 --- a/app/webhooks/stripe/route.ts +++ b/app/webhooks/stripe/route.ts @@ -1,6 +1,7 @@ import { stripe } from "@/services/external/stripe"; import { upsertSubscription } from "@/services/external/stripe/actions/upsert-subscription"; import type Stripe from "stripe"; +import { handleInvoiceCreation } from "./handle-invoice-creation"; import { handleSubscriptionCancellation } from "./handle-subscription-cancellation"; import { handleSubscriptionCycleInvoice } from "./handle-subscription-cycle-invoice"; @@ -85,35 +86,9 @@ export async function POST(req: Request) { break; case "invoice.created": { - console.log(`🔔 Invoice created: ${event.data.object.id}`); + console.log(`🔔 Invoice created: ${event.data.object.id}`); - const invoice = event.data.object; - - if (!invoice.subscription || typeof invoice.subscription !== "string") { - throw new Error( - "Invoice is missing a subscription ID. Please check the invoice data.", - ); - } - - const subscription = await stripe.subscriptions.retrieve( - invoice.subscription, - ); - - if (subscription.status === "canceled") { - try { - await stripe.invoices.finalizeInvoice(invoice.id); - } catch (error) { - console.error(`Error finalizing invoice ${invoice.id}:`, error); - throw new Error("Failed to finalize invoice."); - } - - try { - await stripe.invoices.pay(invoice.id); - } catch (error) { - console.error(`Error paying invoice ${invoice.id}:`, error); - throw new Error("Failed to pay invoice."); - } - } + await handleInvoiceCreation(event.data.object); // TODO: This block will be removed in the other issue. if (event.data.object.billing_reason === "subscription_cycle") { From 6ddee1e731adc302e9d0faa8950d44f0cae6f549 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Jan 2025 14:24:38 +0900 Subject: [PATCH 4/7] fix(webhook): Add subscription update after cancellation Add upsertSubscription call after handleSubscriptionCancellation to ensure subscription data is properly synchronized in the database when a subscription is canceled. This ensures our system maintains accurate subscription state after cancellation processing. --- app/webhooks/stripe/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/webhooks/stripe/route.ts b/app/webhooks/stripe/route.ts index 02c9998b..6b025834 100644 --- a/app/webhooks/stripe/route.ts +++ b/app/webhooks/stripe/route.ts @@ -83,10 +83,11 @@ export async function POST(req: Request) { ); } await handleSubscriptionCancellation(event.data.object); + await upsertSubscription(event.data.object.id); break; - case "invoice.created": { - console.log(`🔔 Invoice created: ${event.data.object.id}`); + case "invoice.created": + console.log(`🔔 Invoice created: ${event.data.object.id}`); await handleInvoiceCreation(event.data.object); @@ -95,7 +96,6 @@ export async function POST(req: Request) { await handleSubscriptionCycleInvoice(event.data.object); } break; - } default: throw new Error("Unhandled relevant event!"); From 49b2633efff73c09e3fc7db55d6779f0b83488e8 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 7 Jan 2025 14:55:26 +0900 Subject: [PATCH 5/7] Remove a comment --- app/webhooks/stripe/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/webhooks/stripe/route.ts b/app/webhooks/stripe/route.ts index 6b025834..85a2c3fe 100644 --- a/app/webhooks/stripe/route.ts +++ b/app/webhooks/stripe/route.ts @@ -91,7 +91,6 @@ export async function POST(req: Request) { await handleInvoiceCreation(event.data.object); - // TODO: This block will be removed in the other issue. if (event.data.object.billing_reason === "subscription_cycle") { await handleSubscriptionCycleInvoice(event.data.object); } From 9ba3e19835db3013037eb765f7fe8bed609f5506 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Thu, 6 Feb 2025 13:27:22 +0900 Subject: [PATCH 6/7] refactor(stripe): simplify invoice payment process - Remove unnecessary finalization step before payment - Directly process invoice payment - Remove error handling wrapper for cleaner flow --- app/webhooks/stripe/handle-invoice-creation.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/app/webhooks/stripe/handle-invoice-creation.ts b/app/webhooks/stripe/handle-invoice-creation.ts index b9d97fa9..e0f7b343 100644 --- a/app/webhooks/stripe/handle-invoice-creation.ts +++ b/app/webhooks/stripe/handle-invoice-creation.ts @@ -16,15 +16,5 @@ export async function handleInvoiceCreation(invoice: Stripe.Invoice) { return; } - await finalizeAndPayInvoice(invoice.id); -} - -async function finalizeAndPayInvoice(invoiceId: string) { - try { - await stripe.invoices.finalizeInvoice(invoiceId); - await stripe.invoices.pay(invoiceId); - } catch (error) { - console.error(`Error processing invoice ${invoiceId}:`, error); - throw new Error("Failed to process invoice"); - } + await stripe.invoices.pay(invoice.id); } From 0ff564170fead58f3a5030a34ca364a312a448f8 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Thu, 6 Feb 2025 14:14:13 +0900 Subject: [PATCH 7/7] Add comments --- app/webhooks/stripe/handle-invoice-creation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/webhooks/stripe/handle-invoice-creation.ts b/app/webhooks/stripe/handle-invoice-creation.ts index e0f7b343..eb4f2ef6 100644 --- a/app/webhooks/stripe/handle-invoice-creation.ts +++ b/app/webhooks/stripe/handle-invoice-creation.ts @@ -16,5 +16,6 @@ export async function handleInvoiceCreation(invoice: Stripe.Invoice) { return; } + // When a subscription is canceled, we should charge for usage-based billing from the previous billing cycle. The final invoice, which includes these charges, will be automatically created but will not be processed for payment. Therefore, we need to handle this case manually. await stripe.invoices.pay(invoice.id); }