billing-stripe.mjs
302 lines 10.7 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Stripe webhook handler + optional session helpers.
3 * Stripe SDK (~220 KB) is lazy-loaded on first webhook call to reduce Lambda cold-start time.
4 */
5 import {
6 MONTHLY_INCLUDED_CENTS_BY_TIER,
7 tierFromEnvPriceId,
8 addonCentsFromPackPriceId,
9 addonTokensFromPackPriceId,
10 addonConsolidationsFromPackPriceId,
11 } from './billing-constants.mjs';
12 import { defaultUserRecord } from './billing-logic.mjs';
13 import {
14 loadBillingDb,
15 mutateBillingDb,
16 eventAlreadyProcessed,
17 markEventProcessed,
18 findUserIdByCustomerId,
19 } from './billing-store.mjs';
20
21 let stripeSingleton = null;
22
23 export async function getStripe() {
24 const key = process.env.STRIPE_SECRET_KEY;
25 if (!key) return null;
26 if (!stripeSingleton) {
27 const { default: Stripe } = await import('stripe');
28 stripeSingleton = new Stripe(key);
29 }
30 return stripeSingleton;
31 }
32
33 function subscriptionPriceId(subscription) {
34 const item = subscription?.items?.data?.[0];
35 return item?.price?.id ?? null;
36 }
37
38 /**
39 * Build a synchronous db mutator for a subscription object.
40 * Returns (db: BillingDb) => void — called inside a single mutateBillingDb.
41 */
42 function buildSubscriptionMutator(sub, explicitUserId) {
43 const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
44 const priceId = subscriptionPriceId(sub);
45 const tier = tierFromEnvPriceId(priceId) || 'plus';
46 const included = MONTHLY_INCLUDED_CENTS_BY_TIER[tier] ?? MONTHLY_INCLUDED_CENTS_BY_TIER.plus;
47
48 return (db) => {
49 let uid = explicitUserId || findUserIdByCustomerId(db, customerId);
50 if (!uid) return;
51 const u = db.users[uid] || defaultUserRecord(uid);
52 db.users[uid] = u;
53 u.stripe_customer_id = customerId;
54 u.stripe_subscription_id = sub.id;
55 if (sub.status === 'active' || sub.status === 'trialing') {
56 u.tier = tier;
57 u.monthly_included_cents = included;
58 u.period_start = new Date(sub.current_period_start * 1000).toISOString();
59 u.period_end = new Date(sub.current_period_end * 1000).toISOString();
60 }
61 if (sub.status === 'canceled' || sub.status === 'unpaid' || sub.status === 'incomplete_expired') {
62 u.tier = 'beta';
63 u.stripe_subscription_id = null;
64 u.monthly_included_cents = 0;
65 }
66 };
67 }
68
69 /**
70 * Prepare all async work for a checkout.session.completed event and return a
71 * synchronous db mutator. Returns null if the event requires no db change.
72 */
73 async function prepareCheckoutMutator(stripe, session) {
74 const uidMeta = session.metadata?.user_id?.trim() || null;
75
76 if (session.mode === 'subscription') {
77 const subId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
78 if (!subId) return null;
79 const sub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
80 return buildSubscriptionMutator(sub, uidMeta);
81 }
82
83 if (session.mode === 'payment') {
84 let creditsCents = parseInt(session.metadata?.credits_cents || '0', 10);
85 let packTokens = parseInt(session.metadata?.indexing_tokens || '0', 10);
86 let packConsolidations = parseInt(session.metadata?.consolidation_passes || '0', 10);
87
88 // Primary source: price_id stored in checkout session metadata at creation time.
89 let resolvedPriceId = session.metadata?.price_id?.trim() || null;
90
91 // Fallback: fetch line items for sessions created before the metadata field was added.
92 if (!resolvedPriceId && stripe) {
93 try {
94 const lineItems = await stripe.checkout.sessions.listLineItems(session.id, {
95 expand: ['data.price'],
96 limit: 1,
97 });
98 resolvedPriceId = lineItems.data?.[0]?.price?.id ?? null;
99 } catch (e) {
100 console.error('[billing] listLineItems failed for session', session.id, e?.message);
101 }
102 }
103
104 if (!creditsCents && resolvedPriceId) {
105 const mapped = addonCentsFromPackPriceId(resolvedPriceId);
106 if (mapped) creditsCents = mapped;
107 }
108 if (!packTokens && resolvedPriceId) {
109 const mapped = addonTokensFromPackPriceId(resolvedPriceId);
110 if (mapped) packTokens = mapped;
111 }
112 if (!packConsolidations && resolvedPriceId) {
113 const mapped = addonConsolidationsFromPackPriceId(resolvedPriceId);
114 if (mapped) packConsolidations = mapped;
115 }
116
117 if (!creditsCents && !packTokens) {
118 console.error('[billing] pack payment: could not resolve credits/tokens for session', session.id, 'price_id:', resolvedPriceId);
119 return null;
120 }
121
122 const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
123 return (db) => {
124 let uid = uidMeta || findUserIdByCustomerId(db, customerId);
125 if (!uid) return;
126 const u = db.users[uid] || defaultUserRecord(uid);
127 db.users[uid] = u;
128 if (customerId) u.stripe_customer_id = customerId;
129 if (creditsCents > 0) u.addon_cents = (Number(u.addon_cents) || 0) + creditsCents;
130 if (packTokens > 0) {
131 u.pack_indexing_tokens_balance =
132 (Math.max(0, Math.floor(Number(u.pack_indexing_tokens_balance) || 0))) + packTokens;
133 }
134 if (packConsolidations > 0) {
135 u.pack_consolidation_passes_balance =
136 (Math.max(0, Math.floor(Number(u.pack_consolidation_passes_balance) || 0))) + packConsolidations;
137 }
138 };
139 }
140
141 return null;
142 }
143
144 /**
145 * Build a synchronous db mutator for an invoice.paid event.
146 */
147 function buildInvoicePaidMutator(invoice) {
148 if (!invoice.subscription) return null;
149 const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
150 const line = invoice.lines?.data?.[0];
151 const periodEnd = line?.period?.end;
152 return (db) => {
153 const uid = findUserIdByCustomerId(db, customerId);
154 if (!uid || !db.users[uid]) return;
155 db.users[uid].monthly_used_cents = 0;
156 if (periodEnd) db.users[uid].period_end = new Date(periodEnd * 1000).toISOString();
157 };
158 }
159
160 /**
161 * Create a Stripe Checkout Session for a subscription or one-time pack purchase.
162 *
163 * @param {{ priceId: string, userId: string, successUrl: string, cancelUrl: string, mode: 'subscription'|'payment', customerEmail?: string|null, stripeCustomerId?: string|null }} opts
164 * @returns {Promise<{ url: string }>}
165 */
166 export async function createCheckoutSession({ priceId, userId, successUrl, cancelUrl, mode, customerEmail, stripeCustomerId }) {
167 const stripe = await getStripe();
168 if (!stripe) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing)'), { code: 'NOT_CONFIGURED' });
169
170 const sessionParams = {
171 mode,
172 line_items: [{ price: priceId, quantity: 1 }],
173 success_url: successUrl,
174 cancel_url: cancelUrl,
175 client_reference_id: userId,
176 // Include price_id so the webhook handler can resolve tokens without a line-item expand.
177 metadata: { user_id: userId, price_id: priceId },
178 };
179
180 if (stripeCustomerId) {
181 sessionParams.customer = stripeCustomerId;
182 } else if (customerEmail) {
183 sessionParams.customer_email = customerEmail;
184 }
185
186 if (mode === 'subscription') {
187 sessionParams.subscription_data = { metadata: { user_id: userId } };
188 }
189
190 const session = await stripe.checkout.sessions.create(sessionParams);
191 return { url: session.url };
192 }
193
194 /**
195 * Look up or create a Stripe Customer for a user, then create a Billing Portal session.
196 *
197 * @param {{ userId: string, returnUrl: string }} opts
198 * @returns {Promise<{ url: string }>}
199 */
200 export async function createPortalSession({ userId, returnUrl }) {
201 const stripe = await getStripe();
202 if (!stripe) throw Object.assign(new Error('Stripe is not configured (STRIPE_SECRET_KEY missing)'), { code: 'NOT_CONFIGURED' });
203
204 const db = await loadBillingDb();
205 const u = db.users[userId];
206 let customerId = u?.stripe_customer_id ?? null;
207
208 if (!customerId) {
209 const customer = await stripe.customers.create({
210 metadata: { user_id: userId },
211 });
212 customerId = customer.id;
213 await mutateBillingDb((dbMut) => {
214 if (!dbMut.users[userId]) dbMut.users[userId] = defaultUserRecord(userId);
215 dbMut.users[userId].stripe_customer_id = customerId;
216 });
217 }
218
219 const portalSession = await stripe.billingPortal.sessions.create({
220 customer: customerId,
221 return_url: returnUrl,
222 });
223
224 return { url: portalSession.url };
225 }
226
227 /**
228 * Express handler: req.body must be raw Buffer (express.raw).
229 */
230 export async function stripeWebhookHandler(req, res) {
231 const secret = process.env.STRIPE_WEBHOOK_SECRET;
232 const stripe = await getStripe();
233 if (!secret || !stripe) {
234 return res.status(503).json({ error: 'Stripe webhook not configured', code: 'NOT_CONFIGURED' });
235 }
236
237 const sig = req.headers['stripe-signature'];
238 if (!sig) {
239 return res.status(400).json({ error: 'Missing stripe-signature', code: 'BAD_REQUEST' });
240 }
241
242 let event;
243 try {
244 event = stripe.webhooks.constructEvent(req.body, sig, secret);
245 } catch (err) {
246 return res.status(400).json({ error: `Webhook signature: ${err.message}`, code: 'BAD_REQUEST' });
247 }
248
249 try {
250 const dbPre = await loadBillingDb();
251 if (eventAlreadyProcessed(dbPre, event.id)) {
252 return res.json({ received: true, duplicate: true });
253 }
254
255 // --- Phase 1: async preparation (Stripe API calls, token resolution) ---
256 // All Stripe network calls happen here, BEFORE any blob writes.
257 let mutate = null;
258 switch (event.type) {
259 case 'checkout.session.completed':
260 mutate = await prepareCheckoutMutator(stripe, event.data.object);
261 break;
262 case 'invoice.paid':
263 mutate = buildInvoicePaidMutator(event.data.object);
264 break;
265 case 'customer.subscription.updated': {
266 const subId = event.data.object?.id;
267 if (subId) {
268 const sub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
269 mutate = buildSubscriptionMutator(sub, null);
270 }
271 break;
272 }
273 case 'customer.subscription.deleted': {
274 const sub = event.data.object;
275 const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
276 mutate = (db) => {
277 const uid = findUserIdByCustomerId(db, customerId);
278 if (!uid || !db.users[uid]) return;
279 db.users[uid].tier = 'beta';
280 db.users[uid].stripe_subscription_id = null;
281 db.users[uid].monthly_included_cents = 0;
282 };
283 break;
284 }
285 default:
286 break;
287 }
288
289 // --- Phase 2: single atomic read-modify-write ---
290 // Applying the event mutation AND marking it processed in ONE mutateBillingDb call
291 // prevents a second load from reading stale data and overwriting the first write.
292 await mutateBillingDb((db) => {
293 if (mutate) mutate(db);
294 markEventProcessed(db, event.id);
295 });
296
297 return res.json({ received: true });
298 } catch (e) {
299 console.error('[billing] webhook handler error', e);
300 return res.status(500).json({ error: 'Webhook processing failed', code: 'SERVER_ERROR' });
301 }
302 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago