fix: sync entity ID
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -67,6 +67,7 @@ export const initImmediateSyncCustomerProduct = ({
|
||||
status: stripeSubscriptionToAutumnStatus({
|
||||
stripeStatus: stripeSubscription.status,
|
||||
}),
|
||||
internalEntityId: plan.internal_entity_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
98
server/tests/_temp/sync-v2-entity-binding.test.ts
Normal file
98
server/tests/_temp/sync-v2-entity-binding.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user