fix: sync entity ID

This commit is contained in:
John Yeo
2026-05-18 20:14:59 +01:00
parent e49c77ed89
commit 6f48b47247
6 changed files with 114 additions and 2 deletions

View File

@@ -83,6 +83,7 @@ export const computeSyncFuturePhases = ({
accessStartsAt: productContext.accessStartsAt,
subscriptionId: stripeSubscription?.id,
subscriptionScheduleId: stripeSchedule?.id,
internalEntityId: productContext.plan.internal_entity_id,
});
insertCustomerProducts.push(cusProduct);
phaseIds.push(cusProduct.id);

View File

@@ -67,6 +67,7 @@ export const initImmediateSyncCustomerProduct = ({
status: stripeSubscriptionToAutumnStatus({
stripeStatus: stripeSubscription.status,
}),
internalEntityId: plan.internal_entity_id,
},
});
};

View File

@@ -40,8 +40,14 @@ export const initCustomerProduct = ({
onTrialEnd,
} = initOptions ?? {};
const internalEntityId = fullCustomer.entity?.internal_id;
const entityId = fullCustomer.entity?.id;
const internalEntityId =
initOptions?.internalEntityId ?? fullCustomer.entity?.internal_id;
const entityId =
initOptions?.internalEntityId && initOptions.internalEntityId !== fullCustomer.entity?.internal_id
? fullCustomer.entities?.find(
(e) => e.internal_id === initOptions.internalEntityId,
)?.id
: fullCustomer.entity?.id;
const startsAt = initOptions?.startsAt ?? now;
const endedAt = initOptions?.endedAt;

View File

@@ -28,6 +28,7 @@ export const initScheduledCustomerProduct = ({
externalId,
subscriptionId,
subscriptionScheduleId,
internalEntityId,
}: {
ctx: AutumnContext;
fullCustomer: FullCustomer;
@@ -44,6 +45,7 @@ export const initScheduledCustomerProduct = ({
* Stripe linkage and downstream actions (cancel, restore) can find it. */
subscriptionId?: string;
subscriptionScheduleId?: string;
internalEntityId?: string;
}): FullCusProduct => {
return initFullCustomerProduct({
ctx,
@@ -64,6 +66,7 @@ export const initScheduledCustomerProduct = ({
externalId,
subscriptionId,
subscriptionScheduleId,
internalEntityId,
},
});
};

View File

@@ -0,0 +1,98 @@
/**
* TDD repro for syncV2 entity binding bug.
*
* Bug: When the caller passes `phases[].plans[].internal_entity_id` to
* `billing.sync_v2`, the inserted customer product is NOT bound to that
* entity. `initCustomerProduct` sources `internal_entity_id` only from
* `fullCustomer.entity` (the customer's currently-set entity context),
* ignoring the plan's intent. The data DOES flow through SyncPlanInstance →
* SyncProductContext.plan (it's even logged in logSyncContext), but
* `initImmediateSyncCustomerProduct` never threads it down to
* `initFullCustomerProduct`, and `InitFullCustomerProductOptions` has no
* field to receive it.
*
* Red (current): cusProduct.internal_entity_id is null after sync.
* Green (after fix): cusProduct.internal_entity_id === plan.internal_entity_id.
*/
import { expect, test } from "bun:test";
import chalk from "chalk";
import { createStripeSubscriptionFromProduct } from "@tests/integration/billing/sync/utils/syncTestUtils";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import { CusService } from "@/internal/customers/CusService";
import { EntityService } from "@/internal/api/entities/EntityService";
test(
chalk.yellowBright(
"sync-v2 entity binding: plan.internal_entity_id must propagate to inserted customer product",
),
async () => {
const customerId = "sync-v2-entity-bind";
const pro = products.pro({
id: "sync-v2-entity-pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 1, featureId: TestFeature.Users }),
],
actions: [],
});
const fullCustomerBefore = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const entityList = await EntityService.list({
db: ctx.db,
internalCustomerId: fullCustomerBefore.internal_id,
});
expect(entityList.length).toBeGreaterThan(0);
const targetEntity = entityList[0];
const targetEntityInternalId = targetEntity.internal_id;
const stripeSubscription = await createStripeSubscriptionFromProduct({
ctx,
customerId,
productId: pro.id,
});
expect(stripeSubscription.status).toBe("active");
await autumnV1.post("/billing.sync_v2", {
customer_id: customerId,
stripe_subscription_id: stripeSubscription.id,
phases: [
{
starts_at: "now",
plans: [
{
plan_id: pro.id,
internal_entity_id: targetEntityInternalId,
},
],
},
],
});
const fullCustomerAfter = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProduct = fullCustomerAfter.customer_products.find(
(cp) => cp.product_id === pro.id,
);
expect(cusProduct).toBeDefined();
expect(cusProduct!.internal_entity_id).toBe(targetEntityInternalId);
},
);

View File

@@ -80,6 +80,9 @@ export interface InitFullCustomerProductOptions {
/** When true, preserve subscription_ids even for non-paid-recurring products (used by sync). */
keepSubscriptionIds?: boolean;
/** Override the entity the customer product is bound to. Used by sync to honor `plan.internal_entity_id` instead of falling back to `fullCustomer.entity`. */
internalEntityId?: string;
previousCustomerProductId?: string;
onTrialEnd?: TrialOnEnd;
}