fix(platform-service): harden register/stripe flows for multi-product correctness
- Make auth register provisioning truly best-effort (warn on failure, do not fail signup) - Process Stripe webhook events for all products (remove non-default skip) - Derive updated subscription plan from Stripe price IDs on subscription.updated - Sync derived plan to auth users and backend plan sync endpoint - Verified: tsc --noEmit clean, 20 test files / 183 tests passing
This commit is contained in:
parent
0c3c109bf1
commit
a699dd9073
@ -96,36 +96,50 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Registration hook: initialize subscription + license using product defaults.
|
// Registration hook: initialize subscription + license using product defaults.
|
||||||
// Best-effort during migration; auth account creation remains primary.
|
// Best-effort during migration; auth account creation remains primary.
|
||||||
await subscriptionRepo.createSubscription({
|
try {
|
||||||
id: `sub_${user.id}_${Date.now()}`,
|
await subscriptionRepo.createSubscription({
|
||||||
productId,
|
id: `sub_${user.id}_${Date.now()}`,
|
||||||
userId: user.id,
|
productId,
|
||||||
plan: initialPlan,
|
userId: user.id,
|
||||||
status: hasTrial ? 'trialing' : 'active',
|
plan: initialPlan,
|
||||||
currentPeriodStart: nowIso,
|
status: hasTrial ? 'trialing' : 'active',
|
||||||
currentPeriodEnd: hasTrial ? trialEnd.toISOString() : nowIso,
|
currentPeriodStart: nowIso,
|
||||||
cancelAtPeriodEnd: false,
|
currentPeriodEnd: hasTrial ? trialEnd.toISOString() : nowIso,
|
||||||
monthlyPrice: 0,
|
cancelAtPeriodEnd: false,
|
||||||
tokensIncluded: 0,
|
monthlyPrice: 0,
|
||||||
tokensUsed: 0,
|
tokensIncluded: 0,
|
||||||
createdAt: nowIso,
|
tokensUsed: 0,
|
||||||
updatedAt: nowIso,
|
createdAt: nowIso,
|
||||||
});
|
updatedAt: nowIso,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn(
|
||||||
|
{ err, userId: user.id, productId },
|
||||||
|
'Subscription provisioning failed during register'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await licenseRepo.create({
|
try {
|
||||||
id: `lic_${crypto.randomUUID()}`,
|
await licenseRepo.create({
|
||||||
productId,
|
id: `lic_${crypto.randomUUID()}`,
|
||||||
key: licenseRepo.generateKey(product.licensePrefix),
|
productId,
|
||||||
userId: user.id,
|
key: licenseRepo.generateKey(product.licensePrefix),
|
||||||
plan: initialPlan,
|
userId: user.id,
|
||||||
status: 'active',
|
plan: initialPlan,
|
||||||
activatedAt: null,
|
status: 'active',
|
||||||
expiresAt: hasTrial ? trialEnd.toISOString() : null,
|
activatedAt: null,
|
||||||
deviceIds: [],
|
expiresAt: hasTrial ? trialEnd.toISOString() : null,
|
||||||
maxDevices: product.deviceLimits[initialPlan],
|
deviceIds: [],
|
||||||
createdAt: nowIso,
|
maxDevices: product.deviceLimits[initialPlan],
|
||||||
updatedAt: nowIso,
|
createdAt: nowIso,
|
||||||
});
|
updatedAt: nowIso,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn(
|
||||||
|
{ err, userId: user.id, productId },
|
||||||
|
'License provisioning failed during register'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = await jwt.createAccessToken({
|
const accessToken = await jwt.createAccessToken({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|||||||
@ -110,10 +110,6 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
// Route by productId in metadata (multi-tenant)
|
// Route by productId in metadata (multi-tenant)
|
||||||
const metadata = getEventMetadata(event);
|
const metadata = getEventMetadata(event);
|
||||||
const eventProductId = metadata?.productId || DEFAULT_PRODUCT_ID;
|
const eventProductId = metadata?.productId || DEFAULT_PRODUCT_ID;
|
||||||
if (eventProductId !== DEFAULT_PRODUCT_ID) {
|
|
||||||
app.log.info(`Ignoring event for product ${eventProductId}`);
|
|
||||||
return { received: true, skipped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
@ -183,19 +179,21 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
||||||
const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId);
|
const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const updatedPlan = getPlanFromSubscription(sub) ?? existing.plan;
|
||||||
const newStatus = sub.cancel_at_period_end
|
const newStatus = sub.cancel_at_period_end
|
||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
: sub.status === 'active'
|
: sub.status === 'active'
|
||||||
? 'active'
|
? 'active'
|
||||||
: 'past_due';
|
: 'past_due';
|
||||||
await subRepo.updateSubscription(existing.id, existing.userId, {
|
await subRepo.updateSubscription(existing.id, existing.userId, {
|
||||||
|
plan: updatedPlan,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||||
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
||||||
});
|
});
|
||||||
await authRepo.updatePlan(existing.userId, eventProductId, existing.plan);
|
await authRepo.updatePlan(existing.userId, eventProductId, updatedPlan);
|
||||||
// Sync plan to backend users container
|
// Sync plan to backend users container
|
||||||
await syncUserPlan(existing.userId, existing.plan, app.log);
|
await syncUserPlan(existing.userId, updatedPlan, app.log);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -279,3 +277,12 @@ function getEventMetadata(event: Stripe.Event): Record<string, string> | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlanFromSubscription(sub: Stripe.Subscription): SubscriptionDoc['plan'] | null {
|
||||||
|
const priceId = sub.items.data[0]?.price?.id;
|
||||||
|
if (!priceId) return null;
|
||||||
|
const priceIds = getPriceIds();
|
||||||
|
if (priceId === priceIds.enterprise) return 'enterprise';
|
||||||
|
if (priceId === priceIds.pro) return 'pro';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user