fix: update subscription next cycle end floored
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingUsages } from "@autumn/shared";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
|
||||
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
const ctx = contexts.create({});
|
||||
|
||||
describe(chalk.yellowBright("applyExistingUsages"), () => {
|
||||
describe("basic usage deduction", () => {
|
||||
test("deducts existing usage from new entitlement balance", () => {
|
||||
@@ -29,6 +32,7 @@ describe(chalk.yellowBright("applyExistingUsages"), () => {
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingUsages } from "@autumn/shared";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
|
||||
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||
import { entities } from "@tests/utils/fixtures/db/entities";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
const ctx = contexts.create({});
|
||||
|
||||
describe(
|
||||
chalk.yellowBright("applyExistingUsages (testing entities flow)"),
|
||||
() => {
|
||||
@@ -67,6 +70,7 @@ describe(
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: entityList,
|
||||
@@ -118,7 +122,12 @@ describe(
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: entityList });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: entityList,
|
||||
});
|
||||
|
||||
// Assert: Entity count (2) takes priority, balance = 10 - 2 = 8
|
||||
const updatedCusEntA = customerProduct.customer_entitlements.find(
|
||||
@@ -173,7 +182,12 @@ describe(
|
||||
const existingUsages: ExistingUsages = {};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: entityList });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: entityList,
|
||||
});
|
||||
|
||||
// Total usage = 3, distributed: first cusEnt uses 2, second cusEnt uses 1
|
||||
const updatedCusEnts = customerProduct.customer_entitlements.filter(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EntInterval, type ExistingUsages } from "@autumn/shared";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
|
||||
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
const ctx = contexts.create({});
|
||||
|
||||
describe(
|
||||
chalk.yellowBright("applyExistingUsages (testing deduction order)"),
|
||||
() => {
|
||||
@@ -44,6 +47,7 @@ describe(
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
@@ -96,7 +100,12 @@ describe(
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert: Prepaid should be depleted first (0), then pay-per-use should have 3 remaining
|
||||
const updatedPrepaid = customerProduct.customer_entitlements.find(
|
||||
@@ -149,7 +158,12 @@ describe(
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
const updatedPrepaid = customerProduct.customer_entitlements.find(
|
||||
(ce) => ce.usage_allowed === false,
|
||||
@@ -217,7 +231,12 @@ describe(
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Find each cusEnt by their unique characteristics
|
||||
const findCusEnt = (usageAllowed: boolean, interval: EntInterval) =>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExistingUsages } from "@autumn/shared";
|
||||
import { contexts } from "@tests/utils/fixtures/db/contexts";
|
||||
import { customerEntitlements } from "@tests/utils/fixtures/db/customerEntitlements";
|
||||
import { customerProducts } from "@tests/utils/fixtures/db/customerProducts";
|
||||
import chalk from "chalk";
|
||||
import { applyExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/applyExistingUsages";
|
||||
|
||||
const ctx = contexts.create({});
|
||||
|
||||
describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
describe("entity usages deduction on entity-scoped cusEnt", () => {
|
||||
test("each entity's balance is reduced by its respective usage", () => {
|
||||
@@ -43,6 +46,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
@@ -88,6 +92,7 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
@@ -134,7 +139,12 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert: Deduction should flow through entities
|
||||
const updatedCusEnt = customerProduct.customer_entitlements[0];
|
||||
@@ -185,7 +195,12 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({ customerProduct, existingUsages, entities: [] });
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert: Entity balances should go negative
|
||||
const updatedCusEnt = customerProduct.customer_entitlements[0];
|
||||
@@ -194,4 +209,154 @@ describe(chalk.yellowBright("applyExistingUsages (entity usages)"), () => {
|
||||
expect(updatedCusEnt.entities?.entity2.balance).toBe(-20); // 30 - 50 = -20
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-entity to per-entity preservation", () => {
|
||||
test("entity usages are preserved when updating from entity-scoped to entity-scoped cusEnt", () => {
|
||||
const internalFeatureId = "internal_feature_a";
|
||||
const entityFeatureId = "entity_feature_id";
|
||||
|
||||
// Entity-scoped cusEnt with 3 entities, each with balance 100
|
||||
const entityScopedCusEnt = customerEntitlements.create({
|
||||
internalFeatureId,
|
||||
featureId: "feature_a",
|
||||
featureName: "Feature A",
|
||||
allowance: 100,
|
||||
balance: 0, // Top-level balance not used for entity-scoped
|
||||
entityFeatureId,
|
||||
entities: {
|
||||
entity1: { id: "entity1", balance: 100, adjustment: 0 },
|
||||
entity2: { id: "entity2", balance: 100, adjustment: 0 },
|
||||
entity3: { id: "entity3", balance: 100, adjustment: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = customerProducts.create({
|
||||
customerEntitlements: [entityScopedCusEnt],
|
||||
});
|
||||
|
||||
// Existing entity usages: entity1 used 30, entity2 used 50, entity3 used 10
|
||||
const existingUsages: ExistingUsages = {
|
||||
[internalFeatureId]: {
|
||||
usage: 0,
|
||||
entityUsages: {
|
||||
entity1: 30,
|
||||
entity2: 50,
|
||||
entity3: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert: Each entity's balance should be reduced by its usage
|
||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
||||
expect(updatedCusEnt.entities).not.toBeNull();
|
||||
expect(updatedCusEnt.entities?.entity1.balance).toBe(70); // 100 - 30
|
||||
expect(updatedCusEnt.entities?.entity2.balance).toBe(50); // 100 - 50
|
||||
expect(updatedCusEnt.entities?.entity3.balance).toBe(90); // 100 - 10
|
||||
});
|
||||
|
||||
test("entity usages are preserved even when some entities have no prior usage", () => {
|
||||
const internalFeatureId = "internal_feature_a";
|
||||
const entityFeatureId = "entity_feature_id";
|
||||
|
||||
// Entity-scoped cusEnt with 3 entities
|
||||
const entityScopedCusEnt = customerEntitlements.create({
|
||||
internalFeatureId,
|
||||
featureId: "feature_a",
|
||||
featureName: "Feature A",
|
||||
allowance: 50,
|
||||
balance: 0,
|
||||
entityFeatureId,
|
||||
entities: {
|
||||
entity1: { id: "entity1", balance: 50, adjustment: 0 },
|
||||
entity2: { id: "entity2", balance: 50, adjustment: 0 },
|
||||
entity3: { id: "entity3", balance: 50, adjustment: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const cusProduct = customerProducts.create({
|
||||
customerEntitlements: [entityScopedCusEnt],
|
||||
});
|
||||
|
||||
// Only entity1 has existing usage, entity2/entity3 have none
|
||||
const existingUsages: ExistingUsages = {
|
||||
[internalFeatureId]: {
|
||||
usage: 0,
|
||||
entityUsages: {
|
||||
entity1: 25,
|
||||
// entity2 and entity3 have no entry - should remain at full balance
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert
|
||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
||||
expect(updatedCusEnt.entities).not.toBeNull();
|
||||
expect(updatedCusEnt.entities?.entity1.balance).toBe(25); // 50 - 25
|
||||
expect(updatedCusEnt.entities?.entity2.balance).toBe(50); // Unchanged
|
||||
expect(updatedCusEnt.entities?.entity3.balance).toBe(50); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("entity to non-entity conversion", () => {
|
||||
test("entity usages are NOT summed when applied to non-entity-scoped cusEnt (current behavior)", () => {
|
||||
const internalFeatureId = "internal_feature_a";
|
||||
|
||||
// Non-entity-scoped cusEnt (no entityFeatureId)
|
||||
const nonEntityScopedCusEnt = customerEntitlements.create({
|
||||
internalFeatureId,
|
||||
featureId: "feature_a",
|
||||
featureName: "Feature A",
|
||||
allowance: 300,
|
||||
balance: 300,
|
||||
// No entityFeatureId - not entity-scoped
|
||||
});
|
||||
|
||||
const cusProduct = customerProducts.create({
|
||||
customerEntitlements: [nonEntityScopedCusEnt],
|
||||
});
|
||||
|
||||
// Entity usages from previous entity-scoped product
|
||||
// entity1: 30, entity2: 50, entity3: 10 = total 90
|
||||
const existingUsages: ExistingUsages = {
|
||||
[internalFeatureId]: {
|
||||
usage: 0, // No top-level usage
|
||||
entityUsages: {
|
||||
entity1: 30,
|
||||
entity2: 50,
|
||||
entity3: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
applyExistingUsages({
|
||||
ctx,
|
||||
customerProduct: cusProduct,
|
||||
existingUsages,
|
||||
entities: [],
|
||||
});
|
||||
|
||||
// Assert: Entity usages should NOT be applied (cusEnt is non-entity-scoped)
|
||||
// This is the CURRENT intentional behavior - entity usages don't convert to top-level
|
||||
const updatedCusEnt = cusProduct.customer_entitlements[0];
|
||||
expect(updatedCusEnt.balance).toBe(300); // Unchanged - entity usages ignored
|
||||
expect(updatedCusEnt.entities).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe(chalk.yellowBright("cusProductToExistingUsages"), () => {
|
||||
expect(existingUsages[internalFeatureId].usage).toBe(30);
|
||||
// Entity usages reflect current balances (not usage)
|
||||
expect(existingUsages[internalFeatureId].entityUsages).toEqual({
|
||||
entity1: 40,
|
||||
entity1: 10,
|
||||
entity2: 25,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BillingInterval, getCycleEnd } from "@autumn/shared";
|
||||
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
|
||||
|
||||
/**
|
||||
* Test suite for getCycleEnd floor parameter
|
||||
*
|
||||
* The floor parameter sets a minimum allowed result. If the calculated cycle end
|
||||
* is before the floor, the floor is returned instead.
|
||||
*
|
||||
* Use case: Long trial periods. When the billing anchor (trial end) is far in the
|
||||
* future, getCycleEnd normally finds the next cycle boundary after `now`, which
|
||||
* "wraps backwards" from the anchor. With floor, we can ensure billing doesn't
|
||||
* start before the trial ends.
|
||||
*
|
||||
* Example: Trial ends 4 Aug (anchor), now is 16 Jan, monthly interval.
|
||||
* Without floor: returns 4 Feb (next monthly boundary after now).
|
||||
* With floor=4 Aug: returns 4 Aug (billing can't start before trial ends).
|
||||
*/
|
||||
describe("get-cycle-end-floor: floor parameter constrains minimum result", () => {
|
||||
describe("Monthly interval with floor - long trial scenario", () => {
|
||||
test("trial ends far in future: anchor 4 Aug, now 16 Jan, floor 4 Aug -> returns 4 Aug", () => {
|
||||
// Trial ends 4 Aug (anchor), now is 16 Jan
|
||||
// Without floor: cycle end would be 4 Feb (wraps backwards from anchor)
|
||||
// With floor: returns 4 Aug (trial end date)
|
||||
const anchor = toUnix({ year: 2026, month: 8, day: 4 });
|
||||
const now = toUnix({ year: 2026, month: 1, day: 16 });
|
||||
const floor = anchor; // Can't bill before trial ends
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2026);
|
||||
expect(month).toBe(8);
|
||||
expect(day).toBe(4);
|
||||
});
|
||||
|
||||
test("200-day trial: anchor 4 Aug, now 16 Jan, floor 4 Aug -> returns anchor", () => {
|
||||
// Simulating the exact bug scenario: 200-day trial
|
||||
const anchor = toUnix({
|
||||
year: 2026,
|
||||
month: 8,
|
||||
day: 4,
|
||||
hour: 13,
|
||||
minute: 45,
|
||||
});
|
||||
const now = toUnix({
|
||||
year: 2026,
|
||||
month: 1,
|
||||
day: 16,
|
||||
hour: 13,
|
||||
minute: 45,
|
||||
});
|
||||
const floor = anchor;
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
|
||||
test("floor not triggered when cycle end is after floor", () => {
|
||||
// Anchor is 2 Jan, now is 15 Jan, floor is 1 Jan
|
||||
// Cycle end is 2 Feb, which is after floor, so floor not used
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const floor = toUnix({ year: 2025, month: 1, day: 1 });
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2025);
|
||||
expect(month).toBe(2);
|
||||
expect(day).toBe(2);
|
||||
});
|
||||
|
||||
test("floor equals calculated end: returns that value", () => {
|
||||
// Edge case: floor is exactly the calculated cycle end
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 20 });
|
||||
const floor = toUnix({ year: 2025, month: 2, day: 15 });
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
|
||||
test("anchor in near future (< 1 interval): without floor wraps correctly, with floor constrains", () => {
|
||||
// Anchor is 28 Feb, now is 15 Jan
|
||||
// Without floor: returns 28 Jan (next boundary)
|
||||
// With floor of 28 Feb: returns 28 Feb
|
||||
const anchor = toUnix({ year: 2025, month: 2, day: 28 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const floor = anchor;
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
const { month, day } = fromUnix(result);
|
||||
expect(month).toBe(2);
|
||||
expect(day).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quarterly interval (intervalCount=3) with floor", () => {
|
||||
test("long trial with quarterly billing: floor constrains result", () => {
|
||||
// Trial ends 15 Jul (anchor, quarterly), now is 1 Feb
|
||||
// Without floor: cycle end would be 15 Apr
|
||||
// With floor: returns 15 Jul
|
||||
const anchor = toUnix({ year: 2025, month: 7, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 2, day: 1 });
|
||||
const floor = anchor;
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 3,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
const { month, day } = fromUnix(result);
|
||||
expect(month).toBe(7);
|
||||
expect(day).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Annual interval with floor", () => {
|
||||
test("long trial with annual billing: floor constrains result", () => {
|
||||
// Trial ends 15 Dec 2025 (anchor), now is 1 Mar 2025
|
||||
// Without floor: cycle end would be 15 Dec 2024 (past!)
|
||||
// Wait, that's not right. Let me recalculate...
|
||||
// Actually for annual: anchor 15 Dec 2025, now 1 Mar 2025
|
||||
// Difference is negative (-9 months), cyclesPassed = floor(-9/12) = -1
|
||||
// Next cycle end = anchor + (-1+1)*12 = 15 Dec 2025
|
||||
// So floor wouldn't be triggered here. Let's use a different scenario.
|
||||
|
||||
// Anchor is 15 Dec 2026, now is 1 Mar 2025
|
||||
// This is a very long trial (21 months)
|
||||
const anchor = toUnix({ year: 2026, month: 12, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 3, day: 1 });
|
||||
const floor = anchor;
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Year,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
// Without floor: would be 15 Dec 2025
|
||||
// With floor: returns 15 Dec 2026
|
||||
expect(result).toBe(floor);
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2026);
|
||||
expect(month).toBe(12);
|
||||
expect(day).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Weekly interval with floor", () => {
|
||||
test("trial ends in 3 weeks: floor constrains result", () => {
|
||||
// Anchor is 28 Jan (3 weeks from now), now is 7 Jan
|
||||
// Without floor: cycle end would be 14 Jan (next weekly boundary)
|
||||
// With floor: returns 28 Jan
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 28 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 7 });
|
||||
const floor = anchor;
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Week,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
const { month, day } = fromUnix(result);
|
||||
expect(month).toBe(1);
|
||||
expect(day).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe("No floor (undefined) maintains original behavior", () => {
|
||||
test("without floor: returns calculated cycle end even if anchor is far future", () => {
|
||||
// Same scenario as first test but without floor
|
||||
const anchor = toUnix({ year: 2026, month: 8, day: 4 });
|
||||
const now = toUnix({ year: 2026, month: 1, day: 16 });
|
||||
|
||||
const result = getCycleEnd({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
// floor not provided
|
||||
});
|
||||
|
||||
// Without floor, returns 4 Feb (next monthly boundary after now)
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2026);
|
||||
expect(month).toBe(2);
|
||||
expect(day).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BillingInterval, getCycleStart } from "@autumn/shared";
|
||||
import { fromUnix, toUnix } from "@tests/utils/testIntervalUtils/testUnixUtils";
|
||||
|
||||
/**
|
||||
* Test suite for getCycleStart floor parameter
|
||||
*
|
||||
* The floor parameter sets a minimum allowed result. If the calculated cycle start
|
||||
* is before the floor, the floor is returned instead.
|
||||
*
|
||||
* Use case: Subscription starts mid-cycle. Without floor, getCycleStart would return
|
||||
* a date before the subscription existed.
|
||||
*
|
||||
* Example: Subscription starts 1 Jan, billing anchor is 15 Jan, now is 5 Jan.
|
||||
* Without floor: returns 15 Dec (previous cycle boundary).
|
||||
* With floor=1 Jan: returns 1 Jan (subscription start).
|
||||
*/
|
||||
describe("get-cycle-start-floor: floor parameter constrains minimum result", () => {
|
||||
describe("Monthly interval with floor", () => {
|
||||
test("subscription starts mid-cycle: anchor 15 Jan, now 5 Jan, floor 1 Jan -> returns 1 Jan", () => {
|
||||
// Subscription created 1 Jan, anchor is 15 Jan, now is 5 Jan
|
||||
// Without floor: cycle start would be 15 Dec (before subscription existed!)
|
||||
// With floor: returns 1 Jan (subscription start)
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 5 });
|
||||
const floor = toUnix({ year: 2025, month: 1, day: 1 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2025);
|
||||
expect(month).toBe(1);
|
||||
expect(day).toBe(1);
|
||||
});
|
||||
|
||||
test("floor not triggered when cycle start is after floor", () => {
|
||||
// Anchor is 2 Jan, now is 15 Feb, floor is 1 Jan
|
||||
// Cycle start is 2 Feb, which is after floor, so floor not used
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 2 });
|
||||
const now = toUnix({ year: 2025, month: 2, day: 15 });
|
||||
const floor = toUnix({ year: 2025, month: 1, day: 1 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2025);
|
||||
expect(month).toBe(2);
|
||||
expect(day).toBe(2);
|
||||
});
|
||||
|
||||
test("floor equals calculated start: returns that value", () => {
|
||||
// Edge case: floor is exactly the calculated cycle start
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 2, day: 20 });
|
||||
const floor = toUnix({ year: 2025, month: 2, day: 15 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
|
||||
test("anchor far in future with floor: returns floor when cycle start would be before subscription", () => {
|
||||
// Anchor is 15 Aug (7 months in future), now is 10 Jan
|
||||
// Without floor: cycle start would be 15 Dec (previous year)
|
||||
// With floor of 1 Jan: returns 1 Jan
|
||||
const anchor = toUnix({ year: 2025, month: 8, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 10 });
|
||||
const floor = toUnix({ year: 2025, month: 1, day: 1 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quarterly interval (intervalCount=3) with floor", () => {
|
||||
test("subscription starts mid-quarter: floor constrains result", () => {
|
||||
// Anchor is 15 Apr (quarterly), now is 20 Feb, subscription started 1 Feb
|
||||
// Without floor: cycle start would be 15 Jan
|
||||
// With floor: returns 1 Feb
|
||||
const anchor = toUnix({ year: 2025, month: 4, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 2, day: 20 });
|
||||
const floor = toUnix({ year: 2025, month: 2, day: 1 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 3,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Annual interval with floor", () => {
|
||||
test("subscription starts mid-year: floor constrains result", () => {
|
||||
// Anchor is 15 Dec 2025, now is 1 Mar 2025, subscription started 1 Feb 2025
|
||||
// Without floor: cycle start would be 15 Dec 2024
|
||||
// With floor: returns 1 Feb 2025
|
||||
const anchor = toUnix({ year: 2025, month: 12, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 3, day: 1 });
|
||||
const floor = toUnix({ year: 2025, month: 2, day: 1 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Year,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
floor,
|
||||
});
|
||||
|
||||
expect(result).toBe(floor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("No floor (undefined) maintains original behavior", () => {
|
||||
test("without floor: returns calculated cycle start even if in the past", () => {
|
||||
// Same scenario as first test but without floor
|
||||
const anchor = toUnix({ year: 2025, month: 1, day: 15 });
|
||||
const now = toUnix({ year: 2025, month: 1, day: 5 });
|
||||
|
||||
const result = getCycleStart({
|
||||
anchor,
|
||||
interval: BillingInterval.Month,
|
||||
intervalCount: 1,
|
||||
now,
|
||||
// floor not provided
|
||||
});
|
||||
|
||||
// Without floor, returns 15 Dec (previous cycle boundary)
|
||||
const { year, month, day } = fromUnix(result);
|
||||
expect(year).toBe(2024);
|
||||
expect(month).toBe(12);
|
||||
expect(day).toBe(15);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,26 +107,5 @@ describe(chalk.yellowBright("computeUpdateSubscriptionIntent"), () => {
|
||||
|
||||
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
|
||||
});
|
||||
|
||||
test("returns UpdatePlan with no options or items", () => {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
...baseParams,
|
||||
};
|
||||
|
||||
const result = computeUpdateSubscriptionIntent(params);
|
||||
|
||||
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
|
||||
});
|
||||
|
||||
test("returns UpdatePlan when options is empty array", () => {
|
||||
const params: UpdateSubscriptionV0Params = {
|
||||
...baseParams,
|
||||
options: [],
|
||||
};
|
||||
|
||||
const result = computeUpdateSubscriptionIntent(params);
|
||||
|
||||
expect(result).toBe(UpdateSubscriptionIntent.UpdatePlan);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user