wip
This commit is contained in:
312
.opencode/plans/invoice-created-refactor.md
Normal file
312
.opencode/plans/invoice-created-refactor.md
Normal 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` | - |
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 ?? [],
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
Reference in New Issue
Block a user