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.
|
||||
// Best-effort during migration; auth account creation remains primary.
|
||||
await subscriptionRepo.createSubscription({
|
||||
id: `sub_${user.id}_${Date.now()}`,
|
||||
productId,
|
||||
userId: user.id,
|
||||
plan: initialPlan,
|
||||
status: hasTrial ? 'trialing' : 'active',
|
||||
currentPeriodStart: nowIso,
|
||||
currentPeriodEnd: hasTrial ? trialEnd.toISOString() : nowIso,
|
||||
cancelAtPeriodEnd: false,
|
||||
monthlyPrice: 0,
|
||||
tokensIncluded: 0,
|
||||
tokensUsed: 0,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
try {
|
||||
await subscriptionRepo.createSubscription({
|
||||
id: `sub_${user.id}_${Date.now()}`,
|
||||
productId,
|
||||
userId: user.id,
|
||||
plan: initialPlan,
|
||||
status: hasTrial ? 'trialing' : 'active',
|
||||
currentPeriodStart: nowIso,
|
||||
currentPeriodEnd: hasTrial ? trialEnd.toISOString() : nowIso,
|
||||
cancelAtPeriodEnd: false,
|
||||
monthlyPrice: 0,
|
||||
tokensIncluded: 0,
|
||||
tokensUsed: 0,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.warn(
|
||||
{ err, userId: user.id, productId },
|
||||
'Subscription provisioning failed during register'
|
||||
);
|
||||
}
|
||||
|
||||
await licenseRepo.create({
|
||||
id: `lic_${crypto.randomUUID()}`,
|
||||
productId,
|
||||
key: licenseRepo.generateKey(product.licensePrefix),
|
||||
userId: user.id,
|
||||
plan: initialPlan,
|
||||
status: 'active',
|
||||
activatedAt: null,
|
||||
expiresAt: hasTrial ? trialEnd.toISOString() : null,
|
||||
deviceIds: [],
|
||||
maxDevices: product.deviceLimits[initialPlan],
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
try {
|
||||
await licenseRepo.create({
|
||||
id: `lic_${crypto.randomUUID()}`,
|
||||
productId,
|
||||
key: licenseRepo.generateKey(product.licensePrefix),
|
||||
userId: user.id,
|
||||
plan: initialPlan,
|
||||
status: 'active',
|
||||
activatedAt: null,
|
||||
expiresAt: hasTrial ? trialEnd.toISOString() : null,
|
||||
deviceIds: [],
|
||||
maxDevices: product.deviceLimits[initialPlan],
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.warn(
|
||||
{ err, userId: user.id, productId },
|
||||
'License provisioning failed during register'
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
|
||||
@ -110,10 +110,6 @@ export async function stripeRoutes(app: FastifyInstance) {
|
||||
// Route by productId in metadata (multi-tenant)
|
||||
const metadata = getEventMetadata(event);
|
||||
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) {
|
||||
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 existing = await subRepo.getByStripeCustomerId(customerId, eventProductId);
|
||||
if (existing) {
|
||||
const updatedPlan = getPlanFromSubscription(sub) ?? existing.plan;
|
||||
const newStatus = sub.cancel_at_period_end
|
||||
? 'cancelled'
|
||||
: sub.status === 'active'
|
||||
? 'active'
|
||||
: 'past_due';
|
||||
await subRepo.updateSubscription(existing.id, existing.userId, {
|
||||
plan: updatedPlan,
|
||||
status: newStatus,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||
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
|
||||
await syncUserPlan(existing.userId, existing.plan, app.log);
|
||||
await syncUserPlan(existing.userId, updatedPlan, app.log);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -279,3 +277,12 @@ function getEventMetadata(event: Stripe.Event): Record<string, string> | 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