fix: 🐛 cleanups
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
getMaxOverage,
|
||||
getRelevantFeatures,
|
||||
isAllocatedCustomerEntitlement,
|
||||
isEntityCusEnt,
|
||||
isFreeCustomerEntitlement,
|
||||
notNullish,
|
||||
orgToInStatuses,
|
||||
@@ -54,7 +53,7 @@ export const prepareFeatureDeduction = ({
|
||||
});
|
||||
|
||||
// Get customer entitlements for these features (includes both product and loose entitlements)
|
||||
let cusEnts = fullCustomerToCustomerEntitlements({
|
||||
const cusEnts = fullCustomerToCustomerEntitlements({
|
||||
fullCustomer,
|
||||
featureIds: relevantFeatures.map((f) => f.id),
|
||||
reverseOrder: org.config?.reverse_deduction_order,
|
||||
@@ -63,10 +62,6 @@ export const prepareFeatureDeduction = ({
|
||||
customerEntitlementFilters,
|
||||
});
|
||||
|
||||
if (fullCustomer.entity?.id && fullCustomer.config?.disable_pooled_balance) {
|
||||
cusEnts = cusEnts.filter((ce) => isEntityCusEnt({ cusEnt: ce }));
|
||||
}
|
||||
|
||||
// Check if ANY relevant feature is unlimited
|
||||
const unlimitedFeatureIds: string[] = [];
|
||||
|
||||
|
||||
@@ -14,12 +14,15 @@ import { setCustomerOverageAllowed } from "../../../integration/balances/utils/o
|
||||
* When customer.config.disable_pooled_balance is true and a deduction is scoped
|
||||
* to an entity, the entity must NOT fall back into the shared customer pool.
|
||||
*
|
||||
* Three deterministic branches:
|
||||
* 1. reject — entity overflow throws InsufficientBalance; nothing deducted
|
||||
* Four deterministic branches:
|
||||
* 1. reject — entity overflow throws InsufficientBalance; nothing deducted
|
||||
* 2. cap (no overage) — entity balance caps at 0; customer pool untouched
|
||||
* 3. cap + overage — entity balance goes to -100; customer pool untouched
|
||||
* 3. cap + overage — entity balance goes to -100; customer pool untouched
|
||||
* 4. entity-attached product — entity has its own plan via `attach(customer_id, entity_id)`;
|
||||
* shared customer pool still blocked
|
||||
*
|
||||
* Shared product: customer pool = 5000, per-entity pool = 500.
|
||||
* Entity-attached product: 500 messages granted directly to the entity.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,28 @@ const makeFreeProd = () =>
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Entity-level free plan. No `entityFeatureId` on the item — instead the plan
|
||||
* is attached directly to an entity via `attach({ customer_id, entity_id })`,
|
||||
* which scopes its balance to that entity through `customer_product.internal_entity_id`.
|
||||
*/
|
||||
const makeEntityLevelProd = () =>
|
||||
products.base({
|
||||
id: "entity-level",
|
||||
items: [items.monthlyMessages({ includedUsage: 500 })],
|
||||
});
|
||||
|
||||
/**
|
||||
* Customer-level shared pool product. No per-entity slice — a single 5000-message
|
||||
* pool that any entity would normally fall back into if disable_pooled_balance
|
||||
* weren't set.
|
||||
*/
|
||||
const makeCustomerPooledProd = () =>
|
||||
products.base({
|
||||
id: "customer-pooled",
|
||||
items: [items.monthlyMessages({ includedUsage: 5000 })],
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Branch 1: reject
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -215,3 +240,85 @@ test.concurrent(`${chalk.yellowBright("track-entity-balances7-overage: entity go
|
||||
const postCustomer = await autumnV1.customers.get(customerId);
|
||||
expect(postCustomer.features[TestFeature.Messages].balance).toBe(4900);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Branch 4: entity-attached product (Plan A attached directly to an entity)
|
||||
// Confirms the filter works for the *other* way an entity can have a balance:
|
||||
// not `X per Entity` (entityFeatureId), but a whole plan attached via
|
||||
// attach({ customer_id, entity_id }).
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
test.concurrent(`${chalk.yellowBright("track-entity-balances7-entity-attached: entity-attached plan works with disable_pooled_balance")}`, async () => {
|
||||
const customerId = "track-entity-balances7-entity-attached";
|
||||
const entityId = "ent-1";
|
||||
const pooledProd = makeCustomerPooledProd();
|
||||
const entityProd = makeEntityLevelProd();
|
||||
|
||||
const { autumnV1 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.deleteCustomer({ customerId }),
|
||||
s.customer({
|
||||
testClock: false,
|
||||
data: { config: { disable_pooled_balance: true } },
|
||||
}),
|
||||
s.products({ list: [pooledProd, entityProd] }),
|
||||
s.entities({ count: 1, featureId: TestFeature.Users }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Attach the pooled product at the customer level. This creates the shared
|
||||
// 5000-message pool that should be blocked from the entity's view.
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: pooledProd.id,
|
||||
});
|
||||
|
||||
// Attach the entity-level plan directly to the entity. Grants the entity
|
||||
// its own 500-message balance via customer_product.internal_entity_id.
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
product_id: entityProd.id,
|
||||
});
|
||||
|
||||
// Verify both attaches landed: customer aggregate view shows 5000 + 500 = 5500.
|
||||
const customerAfterAttach = await autumnV1.customers.get(customerId);
|
||||
expect(customerAfterAttach.features[TestFeature.Messages].balance).toBe(5500);
|
||||
|
||||
// Entity view must show ONLY the entity-attached plan's 500, NOT the 5500
|
||||
// aggregate — the shared pool is blocked by disable_pooled_balance.
|
||||
const preEntity = await autumnV1.entities.get(customerId, entityId);
|
||||
expect(preEntity.features[TestFeature.Messages].balance).toBe(500);
|
||||
|
||||
const preCheck = await autumnV1.check({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
expect(preCheck.balance).toBe(500);
|
||||
|
||||
// Track 300 on the entity. Should deduct from the entity's 500 only.
|
||||
await autumnV1.track({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: TestFeature.Messages,
|
||||
value: 300,
|
||||
skip_event: true,
|
||||
});
|
||||
|
||||
// Entity balance = 500 - 300 = 200.
|
||||
const postEntity = await autumnV1.entities.get(customerId, entityId);
|
||||
expect(postEntity.features[TestFeature.Messages].balance).toBe(200);
|
||||
|
||||
const postCheck = await autumnV1.check({
|
||||
customer_id: customerId,
|
||||
entity_id: entityId,
|
||||
feature_id: TestFeature.Messages,
|
||||
});
|
||||
expect(postCheck.balance).toBe(200);
|
||||
|
||||
// Customer pool untouched: aggregated view = 5000 (pooled) + 200 (entity) = 5200.
|
||||
const postCustomer = await autumnV1.customers.get(customerId);
|
||||
expect(postCustomer.features[TestFeature.Messages].balance).toBe(5200);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isEntityCusEnt } from "@utils/cusEntUtils/cusEntUtils.js";
|
||||
import type { Entity } from "../../../models/cusModels/entityModels/entityModels.js";
|
||||
import type { FullCustomer } from "../../../models/cusModels/fullCusModel.js";
|
||||
import type { CustomerEntitlementFilters } from "../../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||
@@ -106,5 +107,13 @@ export const fullCustomerToCustomerEntitlements = ({
|
||||
);
|
||||
}
|
||||
|
||||
// When disable_pooled_balance is enabled and we're scoped to an entity, drop
|
||||
// customer-level (shared pool) cusEnts so the returned balances reflect
|
||||
// only the entity's own pool. Matches the filter in prepareFeatureDeduction
|
||||
// so the deduction path and reporting stay consistent.
|
||||
if (fullCustomer.entity?.id && fullCustomer.config?.disable_pooled_balance) {
|
||||
cusEnts = cusEnts.filter((ce) => isEntityCusEnt({ cusEnt: ce }));
|
||||
}
|
||||
|
||||
return cusEnts as FullCusEntWithFullCusProduct[];
|
||||
};
|
||||
|
||||
@@ -112,10 +112,12 @@ export const formatTiers = ({
|
||||
export const getFeatureItemDisplay = ({
|
||||
item,
|
||||
feature,
|
||||
entityFeature,
|
||||
fullDisplay = false,
|
||||
}: {
|
||||
item: ProductItem;
|
||||
feature?: Feature;
|
||||
entityFeature?: Feature;
|
||||
fullDisplay?: boolean;
|
||||
}): DisplayResult => {
|
||||
if (!feature) {
|
||||
@@ -130,17 +132,34 @@ export const getFeatureItemDisplay = ({
|
||||
|
||||
const primaryText = getIncludedUsageText(item, feature);
|
||||
|
||||
// Determine secondary text (interval display)
|
||||
// Determine secondary text: per-entity scope + interval.
|
||||
// e.g. "per user per month" when entity_feature_id is set, otherwise "per month".
|
||||
let secondaryText: string | undefined;
|
||||
if (fullDisplay) {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (item.entity_feature_id && entityFeature) {
|
||||
const entityName = getFeatureName({
|
||||
feature: entityFeature,
|
||||
units: 1,
|
||||
});
|
||||
if (entityName) {
|
||||
parts.push(`per ${entityName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const intervalDisplay = getIntervalDisplay(item);
|
||||
if (intervalDisplay) {
|
||||
secondaryText = intervalDisplay;
|
||||
parts.push(intervalDisplay);
|
||||
} else if (
|
||||
isSingleUseFeature(feature) &&
|
||||
item.included_usage !== Infinite
|
||||
) {
|
||||
secondaryText = "one-off";
|
||||
parts.push("one-off");
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
secondaryText = parts.join(" ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +313,16 @@ export const getProductItemDisplay = ({
|
||||
amountFormatOptions?: Intl.NumberFormatOptions;
|
||||
}): DisplayResult => {
|
||||
const findFeature = () => features.find((f) => f.id === item.feature_id);
|
||||
const findEntityFeature = () =>
|
||||
item.entity_feature_id
|
||||
? features.find((f) => f.id === item.entity_feature_id)
|
||||
: undefined;
|
||||
|
||||
if (isFeatureItem(item)) {
|
||||
return getFeatureItemDisplay({
|
||||
item,
|
||||
feature: findFeature(),
|
||||
entityFeature: findEntityFeature(),
|
||||
fullDisplay,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user