This commit is contained in:
John Yeo
2026-01-22 13:31:40 +00:00
parent 53d7a5b896
commit bd17a70371
11 changed files with 452 additions and 15 deletions

View File

@@ -0,0 +1,312 @@
# 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:
1. **Expired Customer Products Cache** - When `subscription.deleted` expires customer products, cache them so `invoice.created` can still access them for processing prepaid/allocated prices
2. **Upsert Autumn Invoice** - Create/update Autumn invoice records on `invoice.created` (skip first invoice)
3. **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`
```typescript
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`
```typescript
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`
```typescript
export { setExpiredCustomerProductsCache } from "./setExpiredCustomerProductsCache";
export { getExpiredCustomerProductsCache } from "./getExpiredCustomerProductsCache";
```
#### A4: Updated `actions/index.ts`
```typescript
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:
```typescript
// 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):
```typescript
// 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`
```typescript
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:
```typescript
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:**
1. **Basic prepaid reset** - Attach with quantity → advance cycle → verify balance resets to quantity * billingUnits
2. **Prepaid with upcoming_quantity** - Set upcoming_quantity mid-cycle → advance cycle → verify balance resets to new quantity
3. **Prepaid lifetime interval** - Lifetime prepaid should NOT reset on cycle (handled specially)
4. **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:**
1. **Replaceables deleted on cycle** - Add seats mid-cycle (creates replaceables with `delete_next_cycle: true`) → advance cycle → verify replaceables removed and balance incremented
2. **No replaceables** - Normal cycle without mid-cycle changes → verify no changes
3. **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
1. **TTL for cache:** Is 5 minutes (300 seconds) appropriate, or should it be longer?
2. **Invoice items:** When creating the Autumn invoice via `upsertAutumnInvoice`, should we populate the `items` array (by calling `getInvoiceItems()`), or leave it empty?
3. **Entity ID logic:** If customer products span multiple entities, what should `internal_entity_id` be? Current plan: only set if ALL customer products have the same entity.
4. **Test migration:** Should we migrate existing tests from `/tests/attach/prepaid/` or create fresh tests following the new `initScenario` pattern?
---
## Dependencies
### Phase A
- `CacheManager` from `@/utils/cacheUtils/CacheManager`
- `FullCusProduct` from `@autumn/shared`
### Phase B
- `InvoiceService` from `@/internal/invoices/InvoiceService`
- `InvoiceCreatedContext` from `setupInvoiceCreatedContext`
### Phase C
- `initScenario`, `s` from `@tests/utils/testInitUtils/initScenario`
- `items`, `products` from test fixtures
- `advanceToNextInvoice` from test utilities
---
## File Summary
| Phase | New Files | Modified Files |
|-------|-----------|----------------|
| A | `expiredCache/setExpiredCustomerProductsCache.ts`<br>`expiredCache/getExpiredCustomerProductsCache.ts`<br>`expiredCache/index.ts` | `actions/index.ts`<br>`expireAndActivateCustomerProducts.ts`<br>`setupInvoiceCreatedContext.ts` |
| B | `tasks/upsertAutumnInvoice.ts` | `handleStripeInvoiceCreated.ts` |
| C | `invoice-created-prepaid.test.ts`<br>`invoice-created-allocated.test.ts` | - |

View File

@@ -103,6 +103,7 @@ export * from "./models/billingModels/invoicingModels/lineItem.js";
export * from "./models/billingModels/newProductAction.js";
export * from "./models/billingModels/stripeAdapterModels/stripeDiscountWithCoupon.js";
export * from "./models/billingModels/stripeAdapterModels/stripeItemSpec.js";
export * from "./models/cusProductModels/cusPriceModels/customerPriceWithCustomerProduct.js";
export * from "./models/migrationModels/migrationErrorTable.js";
export * from "./models/migrationModels/migrationJobTable.js";
export * from "./models/migrationModels/migrationModels.js";

View File

@@ -0,0 +1,12 @@
import { FullCustomerPriceSchema } from "@models/cusProductModels/cusPriceModels/cusPriceModels";
import { FullCusProductSchema } from "@models/cusProductModels/cusProductModels";
import type { z } from "zod/v4";
export const CustomerPriceWithCustomerProductSchema =
FullCustomerPriceSchema.extend({
customer_product: FullCusProductSchema,
});
export type CustomerPriceWithCustomerProduct = z.infer<
typeof CustomerPriceWithCustomerProductSchema
>;

View File

@@ -7,9 +7,11 @@ import { lineItemToPeriodDescription } from "./lineItemToPeriodDescription";
export const usagePriceToLineDescription = ({
usage,
context,
includePeriodDescription = true,
}: {
usage: number;
context: LineItemContext;
includePeriodDescription?: boolean;
}): string => {
const { price, feature } = context;
const billingUnits = price.config.billing_units ?? 1;
@@ -30,7 +32,7 @@ export const usagePriceToLineDescription = ({
const { product } = context;
let description = `${product.name} - ${featureUsageDescription}`;
if (!isOneOffPrice(price)) {
if (!isOneOffPrice(price) && includePeriodDescription) {
const periodDescription = lineItemToPeriodDescription({
context,
});

View File

@@ -17,13 +17,15 @@ import { buildLineItem } from "./buildLineItem";
export const usagePriceToLineItem = ({
cusEnt,
context,
shouldProrateOverride,
chargeImmediatelyOverride,
options = {},
}: {
cusEnt: FullCusEntWithFullCusProduct;
context: LineItemContext;
shouldProrateOverride?: boolean;
chargeImmediatelyOverride?: boolean;
options?: {
shouldProrateOverride?: boolean;
chargeImmediatelyOverride?: boolean;
includePeriodDescription?: boolean;
};
}) => {
const cusPrice = cusEntToCusPrice({ cusEnt });
const { feature } = context;
@@ -74,6 +76,7 @@ export const usagePriceToLineItem = ({
const description = usagePriceToLineDescription({
usage,
context: lineItemContext,
includePeriodDescription: options.includePeriodDescription,
});
// 4. Get amount
@@ -86,7 +89,8 @@ export const usagePriceToLineItem = ({
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
// 6. Should prorate: don't if consumable price (unless override provided)
const shouldProrate = shouldProrateOverride ?? !isConsumablePrice(price);
const shouldProrate =
options.shouldProrateOverride ?? !isConsumablePrice(price);
return buildLineItem({
context,
@@ -97,6 +101,6 @@ export const usagePriceToLineItem = ({
stripeProductId,
shouldProrate,
chargeImmediately: chargeImmediatelyOverride,
chargeImmediately: options.chargeImmediatelyOverride,
});
};

View File

@@ -1,3 +1,5 @@
import { InternalError } from "@api/errors";
import { formatMs, ms } from "@utils/common";
import type {
EntityBalance,
FullCustomerEntitlement,
@@ -68,3 +70,34 @@ export const isAllocatedCustomerEntitlement = (
return true;
};
/**
*
* Only applicable for paid customer entitlements
*/
export const customerEntitlementShouldBeBilled = ({
cusEnt,
invoicePeriodEndMs,
}: {
cusEnt: FullCusEntWithFullCusProduct;
invoicePeriodEndMs: number;
}) => {
if (!isPaidCustomerEntitlement(cusEnt)) {
throw new InternalError({
message: `[customerEntitlementShouldReset] this function is only applicable to paid customer entitlements`,
});
}
const nextResetAt = cusEnt.next_reset_at;
if (!nextResetAt) return false;
const TOLERANCE_MS = ms.minutes(30);
console.log("--------------------------------");
console.log("nextResetAt", formatMs(nextResetAt));
console.log("invoicePeriodEndMs", formatMs(invoicePeriodEndMs));
console.log("--------------------------------");
return nextResetAt <= invoicePeriodEndMs + TOLERANCE_MS;
};

View File

@@ -1,14 +1,30 @@
import { InternalError } from "@api/errors/base/InternalError.js";
import type { FullCusEntWithFullCusProduct } from "../../../models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { FullCustomerPrice } from "../../../models/cusProductModels/cusPriceModels/cusPriceModels";
export const cusEntToCusPrice = ({
// Overload: errorOnNotFound = true → guaranteed FullCustomerPrice
export function cusEntToCusPrice(params: {
cusEnt: FullCusEntWithFullCusProduct;
errorOnNotFound: true;
}): FullCustomerPrice;
// Overload: errorOnNotFound = false/undefined → FullCustomerPrice | undefined
export function cusEntToCusPrice(params: {
cusEnt: FullCusEntWithFullCusProduct;
errorOnNotFound?: false;
}): FullCustomerPrice | undefined;
// Implementation
export function cusEntToCusPrice({
cusEnt,
errorOnNotFound,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}) => {
errorOnNotFound?: boolean;
}): FullCustomerPrice | undefined {
const cusProduct = cusEnt.customer_product;
const cusPrices = cusProduct?.customer_prices ?? [];
return cusPrices.find((cusPrice: FullCustomerPrice) => {
const result = cusPrices.find((cusPrice: FullCustomerPrice) => {
const productMatch =
cusPrice.customer_product_id === cusEnt.customer_product_id;
@@ -16,4 +32,12 @@ export const cusEntToCusPrice = ({
return productMatch && entMatch;
});
};
if (errorOnNotFound && !result) {
throw new InternalError({
message: `Customer price not found for customer_entitlement: ${cusEnt.id}`,
});
}
return result;
}

View File

@@ -0,0 +1,14 @@
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct";
import type { BillingType } from "@models/productModels/priceModels/priceEnums";
import { cusEntToCusPrice } from "@utils/cusEntUtils/convertCusEntUtils/cusEntToCusPrice";
import { getBillingType } from "@utils/productUtils/priceUtils";
export const customerEntitlementToBillingType = ({
cusEnt,
}: {
cusEnt: FullCusEntWithFullCusProduct;
}): BillingType | undefined => {
const customerPrice = cusEntToCusPrice({ cusEnt });
if (!customerPrice) return undefined;
return getBillingType(customerPrice.price.config);
};

View File

@@ -0,0 +1,13 @@
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct";
import { entToOptions } from "@utils/productUtils/convertProductUtils";
export const customerEntitlementToOptions = ({
customerEntitlement,
}: {
customerEntitlement: FullCusEntWithFullCusProduct;
}) => {
return entToOptions({
ent: customerEntitlement.entitlement,
options: customerEntitlement.customer_product?.options ?? [],
});
};

View File

@@ -26,17 +26,16 @@ export * from "./convertCusEntUtils/cusEntsToStartingBalance.js";
export * from "./convertCusEntUtils/cusEntToCusPrice.js";
export * from "./convertCusEntUtils/cusEntToKey.js";
export * from "./convertCusEntUtils/cusEntToStripeIds.js";
// Convert utils barrel
export * from "./convertCusEntUtils/customerEntitlementToOptions.js";
// Convert utils barrel
export * from "./convertCusEntUtils.js";
// Core utils
export * from "./cusEntUtils.js";
export * from "./filterCusEntUtils.js";
export * from "./findCustomerEntitlement/findCustomerEntitlementByFeature.js";
// Find utils
export * from "./findCustomerEntitlement/findCustomerEntitlementById.js";
export * from "./findCustomerEntitlement/findCustomerEntitlementByFeature.js";
export * from "./findCustomerEntitlement/findPrepaidCustomerEntitlement.js";
// Other utils
export * from "./getRolloverFields.js";

View File

@@ -1,6 +1,8 @@
import type { FullCusEntWithFullCusProduct } from "@models/cusProductModels/cusEntModels/cusEntWithProduct.js";
import { customerEntitlementToBillingType } from "@utils/cusEntUtils/convertCusEntUtils/customerEntitlementToBillingType.js";
import { sortCusEntsForDeduction } from "@utils/cusEntUtils/sortCusEntsForDeduction.js";
import { isOneOffPrice } from "@utils/productUtils/priceUtils/classifyPriceUtils.js";
import { notNullish } from "@utils/utils.js";
import type { FullCustomerPrice } from "../../models/cusProductModels/cusPriceModels/cusPriceModels.js";
import type { CusProductStatus } from "../../models/cusProductModels/cusProductEnums.js";
import type {
@@ -64,11 +66,15 @@ export const cusProductsToCusEnts = ({
featureIds,
internalFeatureIds,
inStatuses,
filters,
}: {
cusProducts: FullCusProduct[];
featureIds?: string[];
internalFeatureIds?: string[];
inStatuses?: CusProductStatus[];
filters?: {
billingTypes?: BillingType[];
};
}) => {
let cusEnts: FullCusEntWithFullCusProduct[] = [];
@@ -100,6 +106,15 @@ export const cusProductsToCusEnts = ({
);
}
if (filters?.billingTypes) {
cusEnts = cusEnts.filter((cusEnt) => {
const billingType = customerEntitlementToBillingType({ cusEnt });
return (
notNullish(billingType) && filters?.billingTypes?.includes(billingType)
);
});
}
sortCusEntsForDeduction({
cusEnts,
reverseOrder: false,
@@ -147,6 +162,14 @@ export const cusProductToProduct = ({
} as FullProduct;
};
export const customerProductsToProducts = ({
customerProducts,
}: {
customerProducts: FullCusProduct[];
}): FullProduct[] => {
return customerProducts.map((cp) => cusProductToProduct({ cusProduct: cp }));
};
export const cusProductToCusEnts = ({
customerProduct,
}: {