12 KiB
Invoice Created Webhook Refactor Plan
This document outlines the implementation plan for refactoring the invoice.created webhook handler, adding expired customer products caching, and creating the upsertAutumnInvoice function.
Overview
The invoice.created webhook needs several enhancements:
- Expired Customer Products Cache - When
subscription.deletedexpires customer products, cache them soinvoice.createdcan still access them for processing prepaid/allocated prices - Upsert Autumn Invoice - Create/update Autumn invoice records on
invoice.created(skip first invoice) - Test Coverage - Migrate/create tests for prepaid and allocated price processing
Phase A: Expired Customer Products Cache System ✅ COMPLETED
Goal: Allow subscription.deleted to cache expired customer products so invoice.created can access them.
Problem: When a subscription is deleted, we expire customer products in our DB. But invoice.created may fire shortly after and needs those customer products to process prepaid/allocated prices correctly. Currently, getByStripeSubId with ALL_STATUSES fetches expired products, but there's a race condition risk.
Solution: Cache expired customer products in Redis when they're expired, then merge them in setupInvoiceCreatedContext.
Tasks
| Task | Description | File(s) |
|---|---|---|
| A1 | Create setExpiredCustomerProductsCache.ts |
server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts |
| A2 | Create getExpiredCustomerProductsCache.ts |
server/src/internal/customers/cusProducts/actions/expiredCache/getExpiredCustomerProductsCache.ts |
| A3 | Create expiredCache/index.ts barrel export |
server/src/internal/customers/cusProducts/actions/expiredCache/index.ts |
| A4 | Update actions/index.ts to add expiredCache: { set, get } |
server/src/internal/customers/cusProducts/actions/index.ts |
| A5 | Update expireAndActivateCustomerProducts.ts to call expiredCache.set() at end |
server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts |
| A6 | Update setupInvoiceCreatedContext.ts to call expiredCache.get() and merge |
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts |
File Details
A1: setExpiredCustomerProductsCache.ts
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const setExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
customerProducts,
}: {
stripeSubscriptionId: string;
customerProducts: FullCusProduct[];
}): Promise<void> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
// 5 minute TTL - enough time for invoice.created to process
await CacheManager.setJson(key, customerProducts, 300);
};
A2: getExpiredCustomerProductsCache.ts
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const getExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
}: {
stripeSubscriptionId: string;
}): Promise<FullCusProduct[] | null> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
return await CacheManager.getJson<FullCusProduct[]>(key);
};
A3: expiredCache/index.ts
export { setExpiredCustomerProductsCache } from "./setExpiredCustomerProductsCache";
export { getExpiredCustomerProductsCache } from "./getExpiredCustomerProductsCache";
A4: Updated actions/index.ts
import { activateScheduledCustomerProduct } from "./activateScheduled";
import { deleteScheduledCustomerProduct } from "./deleteScheduledCustomerProduct";
import { expireCustomerProductAndActivateDefault } from "./expireAndActivateDefault";
import { setExpiredCustomerProductsCache, getExpiredCustomerProductsCache } from "./expiredCache";
export const customerProductActions = {
expireAndActivateDefault: expireCustomerProductAndActivateDefault,
activateScheduled: activateScheduledCustomerProduct,
deleteScheduled: deleteScheduledCustomerProduct,
expiredCache: {
set: setExpiredCustomerProductsCache,
get: getExpiredCustomerProductsCache,
},
};
export {
expireCustomerProductAndActivateDefault,
activateScheduledCustomerProduct,
deleteScheduledCustomerProduct,
};
A5: Changes to expireAndActivateCustomerProducts.ts
At the end of the function, after processing all customer products:
// Cache the expired products for invoice.created
await customerProductActions.expiredCache.set({
stripeSubscriptionId: stripeSubscription.id,
customerProducts,
});
A6: Changes to setupInvoiceCreatedContext.ts
After fetching customer products from DB (~line 77):
// Merge in any cached expired customer products
const cachedExpired = await customerProductActions.expiredCache.get({
stripeSubscriptionId,
});
if (cachedExpired && cachedExpired.length > 0) {
const existingIds = new Set(customerProducts.map(cp => cp.id));
const expiredToAdd = cachedExpired.filter(cp => !existingIds.has(cp.id));
customerProducts.push(...expiredToAdd);
logger.info(
`[invoice.created] Added ${expiredToAdd.length} cached expired products`,
);
}
Phase B: Upsert Autumn Invoice on invoice.created ✅ COMPLETED
Goal: Create/update Autumn invoice record when Stripe sends invoice.created webhook, but skip the first invoice (billing_reason: subscription_create).
Reference: Similar to upsertInvoiceFromBilling.ts but adapted for webhook context.
Tasks
| Task | Description | File(s) |
|---|---|---|
| B1 | Create upsertAutumnInvoice.ts task |
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts |
| B2 | Update handleStripeInvoiceCreated.ts to call upsertAutumnInvoice() |
server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts |
File Details
B1: upsertAutumnInvoice.ts
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
export const upsertAutumnInvoice = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
}): Promise<void> => {
const { stripeInvoice, customerProducts, fullCustomer } = eventContext;
// Skip first invoice (subscription_create)
if (stripeInvoice.billing_reason === "subscription_create") {
ctx.logger.debug("[invoice.created] Skipping invoice upsert for subscription_create");
return;
}
const productIds = [...new Set(customerProducts.map(cp => cp.product.id))];
const internalProductIds = [...new Set(customerProducts.map(cp => cp.internal_product_id))];
const internalCustomerId = fullCustomer.internal_id;
// Entity ID - if all customer products have same entity, use it
const internalEntityId = customerProducts.length > 0 && customerProducts.every(
cp => cp.internal_entity_id === customerProducts[0].internal_entity_id
) ? customerProducts[0].internal_entity_id : null;
// Try update first
const updated = await InvoiceService.updateByStripeId({
db: ctx.db,
stripeId: stripeInvoice.id,
updates: {
product_ids: productIds,
internal_product_ids: internalProductIds,
},
});
if (updated) return;
// Create new
await InvoiceService.createInvoiceFromStripe({
db: ctx.db,
stripeInvoice,
internalCustomerId,
internalEntityId,
org: ctx.org,
productIds,
internalProductIds,
items: [],
});
};
B2: Changes to handleStripeInvoiceCreated.ts
Add import and call after price processing:
import { upsertAutumnInvoice } from "./tasks/upsertAutumnInvoice";
// ... existing code ...
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
// Upsert Autumn invoice record
await upsertAutumnInvoice({ ctx, eventContext });
Phase C: Migrate/Create Tests for invoice.created Prepaid & Allocated Prices
Goal: Ensure test coverage for the refactored processPrepaidPricesForInvoiceCreated.ts and processAllocatedPricesForInvoiceCreated.ts.
Location: server/tests/integration/billing/stripe-webhooks/invoice-created/
Tasks
| Task | Description | File(s) |
|---|---|---|
| C1 | Create invoice-created-prepaid.test.ts |
server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid.test.ts |
| C2 | Create invoice-created-allocated.test.ts |
server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-allocated.test.ts |
Test Scenarios
C1: invoice-created-prepaid.test.ts
Tests for processPrepaidPricesForInvoiceCreated.ts (UsageInAdvance billing type).
Scenarios to test:
- Basic prepaid reset - Attach with quantity → advance cycle → verify balance resets to quantity * billingUnits
- Prepaid with upcoming_quantity - Set upcoming_quantity mid-cycle → advance cycle → verify balance resets to new quantity
- Prepaid lifetime interval - Lifetime prepaid should NOT reset on cycle (handled specially)
- Prepaid with rollover - If rollover is configured, verify rollover records are created
Reference existing tests: /server/tests/attach/prepaid/prepaid1.test.ts, prepaid3.test.ts
C2: invoice-created-allocated.test.ts
Tests for processAllocatedPricesForInvoiceCreated.ts (InArrearProrated billing type).
Scenarios to test:
- Replaceables deleted on cycle - Add seats mid-cycle (creates replaceables with
delete_next_cycle: true) → advance cycle → verify replaceables removed and balance incremented - No replaceables - Normal cycle without mid-cycle changes → verify no changes
- Multiple linked entitlements - Replaceables affect multiple linked customer entitlements
Reference existing tests: /server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts (line 279: "replaceables deleted at end of cycle")
Open Questions
-
TTL for cache: Is 5 minutes (300 seconds) appropriate, or should it be longer?
-
Invoice items: When creating the Autumn invoice via
upsertAutumnInvoice, should we populate theitemsarray (by callinggetInvoiceItems()), or leave it empty? -
Entity ID logic: If customer products span multiple entities, what should
internal_entity_idbe? Current plan: only set if ALL customer products have the same entity. -
Test migration: Should we migrate existing tests from
/tests/attach/prepaid/or create fresh tests following the newinitScenariopattern?
Dependencies
Phase A
CacheManagerfrom@/utils/cacheUtils/CacheManagerFullCusProductfrom@autumn/shared
Phase B
InvoiceServicefrom@/internal/invoices/InvoiceServiceInvoiceCreatedContextfromsetupInvoiceCreatedContext
Phase C
initScenario,sfrom@tests/utils/testInitUtils/initScenarioitems,productsfrom test fixturesadvanceToNextInvoicefrom test utilities
File Summary
| Phase | New Files | Modified Files |
|---|---|---|
| A | expiredCache/setExpiredCustomerProductsCache.tsexpiredCache/getExpiredCustomerProductsCache.tsexpiredCache/index.ts |
actions/index.tsexpireAndActivateCustomerProducts.tssetupInvoiceCreatedContext.ts |
| B | tasks/upsertAutumnInvoice.ts |
handleStripeInvoiceCreated.ts |
| C | invoice-created-prepaid.test.tsinvoice-created-allocated.test.ts |
- |