git-paid 0.0.0-stage → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/stripe.js ADDED
@@ -0,0 +1,554 @@
1
+ // Stripe Payments, Billing & Invoicing Engine for postbuild (www.postbuild.dev)
2
+ // Follows Stripe Best Practices (API version 2026-10-28.endive, StripeClient instance,
3
+ // dynamic payment methods, integration_identifier, invoice_creation, Customer Portal, and Webhooks).
4
+
5
+ import Stripe from "stripe";
6
+ import { signLicenseToken } from "./auth.js";
7
+
8
+ export const STRIPE_API_VERSION = "2026-10-28.endive";
9
+ export const INTEGRATION_IDENTIFIER = "postbuild_checkout_x7k9m2pq";
10
+
11
+ // Separate Product definitions per plan (never mix tiers on a single Product)
12
+ export const PLANS = {
13
+ launch_pass: {
14
+ id: "launch_pass",
15
+ productName: "postbuild Launch Pass (git paid)",
16
+ description: "25 Programmatic SEO pages + Dedicated @postbuild.dev verification inbox + 85-Directory Dispatch",
17
+ amountCents: 2900,
18
+ mode: "payment",
19
+ priceEnvVar: "STRIPE_PRICE_LAUNCH_PASS"
20
+ },
21
+ studio_monthly: {
22
+ id: "studio_monthly",
23
+ productName: "postbuild Studio All-Access",
24
+ description: "Unlimited projects, weekly programmatic SEO route drops, IndexNow pinging, and Buyer Intent Radar",
25
+ amountCents: 4900,
26
+ mode: "subscription",
27
+ priceEnvVar: "STRIPE_PRICE_STUDIO_MONTHLY"
28
+ }
29
+ };
30
+
31
+ export function getStripeClient() {
32
+ const candidates = [
33
+ process.env.STRIPE_RESTRICTED_KEY,
34
+ process.env.STRIPE_SECRET_KEY,
35
+ process.env.STRIPE_LIVE_KEY,
36
+ process.env.STRIPE_API_KEY
37
+ ]
38
+ .map((k) => (k || "").trim())
39
+ .filter(Boolean);
40
+
41
+ if (candidates.length === 0) return null;
42
+
43
+ // Always prefer a live key (rk_live_ or sk_live_) if one is configured alongside a test key
44
+ const liveKey = candidates.find((k) => k.startsWith("rk_live_") || k.startsWith("sk_live_"));
45
+ const apiKey = liveKey || candidates[0];
46
+ return new Stripe(apiKey, { apiVersion: STRIPE_API_VERSION });
47
+ }
48
+
49
+ export async function createStripeCheckout({
50
+ planId = "launch_pass",
51
+ targetDomain = "postbuild.dev",
52
+ customerEmail = "",
53
+ coupon = "",
54
+ origin = "https://postbuild.dev"
55
+ }) {
56
+ const plan = PLANS[planId] || PLANS.launch_pass;
57
+
58
+ // 1. Optional Payment Link override if configured
59
+ if (plan.id === "launch_pass" && process.env.STRIPE_LAUNCH_PASS_URL) {
60
+ const link = new URL(process.env.STRIPE_LAUNCH_PASS_URL);
61
+ link.searchParams.set("client_reference_id", targetDomain);
62
+ if (customerEmail) link.searchParams.set("prefilled_email", customerEmail);
63
+ return { ok: true, configured: true, mode: "payment_link", checkoutUrl: link.toString() };
64
+ }
65
+ if (plan.id === "studio_monthly" && process.env.STRIPE_STUDIO_URL) {
66
+ const link = new URL(process.env.STRIPE_STUDIO_URL);
67
+ link.searchParams.set("client_reference_id", targetDomain);
68
+ if (customerEmail) link.searchParams.set("prefilled_email", customerEmail);
69
+ return { ok: true, configured: true, mode: "payment_link", checkoutUrl: link.toString() };
70
+ }
71
+
72
+ // 2. Instantiate Stripe client
73
+ const stripe = getStripeClient();
74
+ if (!stripe) {
75
+ return {
76
+ ok: false,
77
+ configured: false,
78
+ plan,
79
+ message: "Stripe API key not configured yet. Add STRIPE_RESTRICTED_KEY (rk_...) or STRIPE_SECRET_KEY in Vercel / .env.",
80
+ setupSteps: [
81
+ "Recommended: Create a Restricted API Key (rk_live_... or rk_test_...) with Checkout Sessions (Write), Customers (Read/Write), Billing Portal (Write), and Invoices (Read/Write) permissions.",
82
+ "Set STRIPE_RESTRICTED_KEY and STRIPE_WEBHOOK_SECRET in Vercel -> Settings -> Environment Variables (as Sensitive variables) or local .env."
83
+ ]
84
+ };
85
+ }
86
+
87
+ const isFounder100 = String(coupon || "").toUpperCase() === "FOUNDER100";
88
+ let activeCouponId = null;
89
+ if (isFounder100) {
90
+ try {
91
+ try {
92
+ const existingCoupon = await stripe.coupons.retrieve("FOUNDER100_COUPON");
93
+ activeCouponId = existingCoupon.id;
94
+ } catch {
95
+ const createdCoupon = await stripe.coupons.create({
96
+ id: "FOUNDER100_COUPON",
97
+ percent_off: 100,
98
+ duration: "forever",
99
+ name: "postbuild Founder 100% Pass"
100
+ });
101
+ activeCouponId = createdCoupon.id;
102
+ }
103
+ } catch {
104
+ activeCouponId = null;
105
+ }
106
+ }
107
+
108
+ // Build line_item using catalog Price ID if configured, or distinct product_data per tier
109
+ const configuredPriceId = (process.env[plan.priceEnvVar] || "").trim();
110
+ const productTaxCode = (process.env.STRIPE_PRODUCT_TAX_CODE || "txcd_10103001").trim();
111
+ const effectiveUnitAmount = isFounder100 && !activeCouponId ? 0 : plan.amountCents;
112
+
113
+ const lineItem =
114
+ configuredPriceId && !isFounder100
115
+ ? { price: configuredPriceId, quantity: 1 }
116
+ : {
117
+ quantity: 1,
118
+ price_data: {
119
+ currency: "usd",
120
+ unit_amount: effectiveUnitAmount,
121
+ tax_behavior: "exclusive",
122
+ ...(plan.mode === "subscription" ? { recurring: { interval: "month" } } : {}),
123
+ product_data: {
124
+ name: isFounder100 ? `${plan.productName} (Founder 100% Pass)` : plan.productName,
125
+ description: plan.description,
126
+ tax_code: productTaxCode
127
+ }
128
+ }
129
+ };
130
+
131
+ // Omit payment_method_types entirely so Stripe Dynamic Payment Methods are active
132
+ const sessionParams = {
133
+ mode: plan.mode,
134
+ line_items: [lineItem],
135
+ ...(activeCouponId ? { discounts: [{ coupon: activeCouponId }] } : { allow_promotion_codes: true }),
136
+ ...(plan.mode === "payment" ? { customer_creation: "always" } : {}),
137
+ success_url: `${origin}/?checkout=success&session_id={CHECKOUT_SESSION_ID}&plan=${encodeURIComponent(plan.id)}&domain=${encodeURIComponent(targetDomain)}`,
138
+ cancel_url: `${origin}/?checkout=cancelled&domain=${encodeURIComponent(targetDomain)}`,
139
+ client_reference_id: targetDomain,
140
+ integration_identifier: INTEGRATION_IDENTIFIER,
141
+ metadata: {
142
+ target_domain: targetDomain,
143
+ plan_id: plan.id,
144
+ promo_code: isFounder100 ? "FOUNDER100" : ""
145
+ }
146
+ };
147
+
148
+ if (customerEmail && customerEmail.includes("@")) {
149
+ sessionParams.customer_email = customerEmail;
150
+ }
151
+
152
+ const session = await stripe.checkout.sessions.create(sessionParams);
153
+
154
+ return {
155
+ ok: true,
156
+ configured: true,
157
+ livemode: Boolean(session.livemode),
158
+ mode: "checkout_session",
159
+ couponApplied: isFounder100 ? (activeCouponId || "FOUNDER100_ZERO_AMOUNT") : null,
160
+ amountTotalCents: session.amount_total ?? effectiveUnitAmount,
161
+ sessionId: session.id,
162
+ checkoutUrl: session.url
163
+ };
164
+ }
165
+
166
+ export async function createCustomerPortalSession({ customerId, customerEmail, returnUrl = "https://postbuild.dev" }) {
167
+ const stripe = getStripeClient();
168
+ if (!stripe) {
169
+ return { ok: false, configured: false, error: "Stripe API key not configured." };
170
+ }
171
+
172
+ let resolvedCustomerId = customerId;
173
+ const cleanEmail = (customerEmail || "").toLowerCase().trim();
174
+ if (!resolvedCustomerId && cleanEmail) {
175
+ const list = await stripe.customers.list({ email: cleanEmail, limit: 1 });
176
+ if (list.data.length > 0) {
177
+ resolvedCustomerId = list.data[0].id;
178
+ } else {
179
+ // If the user previously checked out as a guest before customer_creation: 'always', create their Customer record now
180
+ const created = await stripe.customers.create({
181
+ email: cleanEmail,
182
+ metadata: { source: "postbuild_portal_auto_link" }
183
+ });
184
+ resolvedCustomerId = created.id;
185
+ }
186
+ }
187
+
188
+ if (!resolvedCustomerId) {
189
+ return { ok: false, error: "Please sign in with your checkout email first to open the Billing Portal." };
190
+ }
191
+
192
+ const portalSession = await stripe.billingPortal.sessions.create({
193
+ customer: resolvedCustomerId,
194
+ return_url: returnUrl
195
+ });
196
+
197
+ return {
198
+ ok: true,
199
+ portalUrl: portalSession.url
200
+ };
201
+ }
202
+
203
+ export async function getCustomerBillingSummary({ customerEmail, customerId }) {
204
+ const stripe = getStripeClient();
205
+ if (!stripe) {
206
+ return { ok: false, configured: false, receipts: [], subscriptions: [] };
207
+ }
208
+
209
+ const cleanEmail = (customerEmail || "").toLowerCase().trim();
210
+ const receipts = [];
211
+ const subscriptions = [];
212
+ let resolvedCustomerId = customerId || null;
213
+
214
+ try {
215
+ if (!resolvedCustomerId && cleanEmail) {
216
+ const custList = await stripe.customers.list({ email: cleanEmail, limit: 3 });
217
+ if (custList.data.length > 0) {
218
+ resolvedCustomerId = custList.data[0].id;
219
+ }
220
+ }
221
+
222
+ // 1. Fetch official Stripe Invoices if customer exists
223
+ if (resolvedCustomerId) {
224
+ const invList = await stripe.invoices.list({ customer: resolvedCustomerId, limit: 10 });
225
+ for (const inv of invList.data) {
226
+ receipts.push({
227
+ id: inv.id,
228
+ number: inv.number || inv.id.slice(0, 12),
229
+ description: inv.lines?.data?.[0]?.description || "postbuild Subscription / Launch Pass",
230
+ amountFormatted: `$${((inv.amount_paid || inv.total || 0) / 100).toFixed(2)} ${(inv.currency || "usd").toUpperCase()}`,
231
+ status: inv.status || "paid",
232
+ createdAt: new Date(inv.created * 1000).toISOString().split("T")[0],
233
+ receiptUrl: inv.hosted_invoice_url || inv.invoice_pdf || null,
234
+ pdfUrl: inv.invoice_pdf || null
235
+ });
236
+ }
237
+
238
+ const subList = await stripe.subscriptions.list({ customer: resolvedCustomerId, status: "all", limit: 5 });
239
+ for (const sub of subList.data) {
240
+ subscriptions.push({
241
+ id: sub.id,
242
+ status: sub.status,
243
+ cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
244
+ currentPeriodEnd: sub.current_period_end
245
+ ? new Date(sub.current_period_end * 1000).toISOString().split("T")[0]
246
+ : null
247
+ });
248
+ }
249
+ }
250
+
251
+ // 2. Also inspect recent completed Checkout Sessions matching this email (catches one-time guest checkouts & PaymentIntent receipts)
252
+ const sessions = await stripe.checkout.sessions.list({ limit: 25, expand: ["data.payment_intent.latest_charge"] });
253
+ for (const s of sessions.data) {
254
+ const sEmail = (s.customer_details?.email || s.customer_email || "").toLowerCase().trim();
255
+ if (cleanEmail && sEmail !== cleanEmail) continue;
256
+ if (s.payment_status === "unpaid") continue;
257
+
258
+ const charge = s.payment_intent && typeof s.payment_intent === "object" ? s.payment_intent.latest_charge : null;
259
+ const receiptUrl = charge && typeof charge === "object" ? charge.receipt_url : null;
260
+ const planId = s.metadata?.plan_id || (s.mode === "subscription" ? "studio_monthly" : "launch_pass");
261
+ const domain = s.client_reference_id || s.metadata?.target_domain || "postbuild.dev";
262
+
263
+ // Avoid duplicating if already added via invoice
264
+ if (!receipts.some((r) => r.id === s.id)) {
265
+ receipts.push({
266
+ id: s.id,
267
+ number: (charge && typeof charge === "object" && charge.receipt_number) || s.id.slice(-10).toUpperCase(),
268
+ description:
269
+ planId === "studio_monthly"
270
+ ? `postbuild Studio All-Access (${domain})`
271
+ : `postbuild Single Project Launch Pass (${domain})`,
272
+ amountFormatted: `$${((s.amount_total || 2900) / 100).toFixed(2)} ${(s.currency || "usd").toUpperCase()}`,
273
+ status: s.payment_status || "paid",
274
+ createdAt: new Date(s.created * 1000).toISOString().split("T")[0],
275
+ receiptUrl: receiptUrl || null,
276
+ pdfUrl: null
277
+ });
278
+ }
279
+ }
280
+ } catch (err) {
281
+ return { ok: false, error: err.message, receipts, subscriptions };
282
+ }
283
+
284
+ return {
285
+ ok: true,
286
+ configured: true,
287
+ customerId: resolvedCustomerId,
288
+ email: cleanEmail,
289
+ receipts,
290
+ subscriptions
291
+ };
292
+ }
293
+
294
+ export async function cancelCustomerSubscription({ subscriptionId, customerEmail }) {
295
+ const stripe = getStripeClient();
296
+ if (!stripe) {
297
+ return { ok: false, error: "Stripe API key not configured." };
298
+ }
299
+
300
+ let targetSubId = subscriptionId;
301
+ const cleanEmail = (customerEmail || "").toLowerCase().trim();
302
+
303
+ if (!targetSubId && cleanEmail) {
304
+ const custList = await stripe.customers.list({ email: cleanEmail, limit: 1 });
305
+ if (custList.data.length > 0) {
306
+ const subList = await stripe.subscriptions.list({
307
+ customer: custList.data[0].id,
308
+ status: "active",
309
+ limit: 1
310
+ });
311
+ if (subList.data.length > 0) {
312
+ targetSubId = subList.data[0].id;
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!targetSubId) {
318
+ return { ok: false, error: "No active recurring subscription found for this account." };
319
+ }
320
+
321
+ const updated = await stripe.subscriptions.update(targetSubId, {
322
+ cancel_at_period_end: true
323
+ });
324
+
325
+ return {
326
+ ok: true,
327
+ subscriptionId: updated.id,
328
+ status: updated.status,
329
+ cancelAtPeriodEnd: Boolean(updated.cancel_at_period_end),
330
+ currentPeriodEnd: updated.current_period_end
331
+ ? new Date(updated.current_period_end * 1000).toISOString().split("T")[0]
332
+ : null
333
+ };
334
+ }
335
+
336
+ export async function fulfillCheckoutSession(session) {
337
+ // Gate fulfillment strictly on payment_status !== 'unpaid' (handles delayed-notification payment methods safely)
338
+ if (!session || session.payment_status === "unpaid") {
339
+ return { fulfilled: false, reason: "Payment status is still unpaid" };
340
+ }
341
+
342
+ const customerEmail = session.customer_details?.email || session.customer_email || "";
343
+ const targetDomain = session.client_reference_id || session.metadata?.target_domain || "*";
344
+ const planId = session.metadata?.plan_id || (session.mode === "subscription" ? "studio_monthly" : "launch_pass");
345
+
346
+ const licenseToken = signLicenseToken({
347
+ email: customerEmail,
348
+ plan: planId,
349
+ domain: targetDomain
350
+ });
351
+
352
+ return {
353
+ fulfilled: true,
354
+ sessionId: session.id,
355
+ customerId: typeof session.customer === "string" ? session.customer : session.customer?.id || null,
356
+ customerEmail,
357
+ planId,
358
+ targetDomain,
359
+ licenseToken
360
+ };
361
+ }
362
+
363
+ export async function verifyAndHandleStripeWebhook(rawBodyBuffer, signatureHeader) {
364
+ const stripe = getStripeClient();
365
+ const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || "").trim();
366
+
367
+ if (!stripe || !webhookSecret) {
368
+ return { ok: false, status: 400, error: "Stripe client or STRIPE_WEBHOOK_SECRET is not configured." };
369
+ }
370
+
371
+ let event;
372
+ try {
373
+ event = stripe.webhooks.constructEvent(rawBodyBuffer, signatureHeader, webhookSecret);
374
+ } catch (err) {
375
+ return { ok: false, status: 400, error: `Webhook signature verification failed: ${err.message}` };
376
+ }
377
+
378
+ switch (event.type) {
379
+ // 1. Checkout & Async Payment Fulfillment
380
+ case "checkout.session.completed":
381
+ case "checkout.session.async_payment_succeeded": {
382
+ const session = event.data.object;
383
+ const result = await fulfillCheckoutSession(session);
384
+ return { ok: true, status: 200, eventType: event.type, fulfillment: result };
385
+ }
386
+
387
+ case "checkout.session.async_payment_failed": {
388
+ const session = event.data.object;
389
+ return {
390
+ ok: true,
391
+ status: 200,
392
+ eventType: event.type,
393
+ action: "payment_failed_notification",
394
+ sessionId: session.id
395
+ };
396
+ }
397
+
398
+ // 2. Subscription & Invoicing Lifecycle
399
+ case "customer.subscription.created":
400
+ case "customer.subscription.updated":
401
+ case "customer.subscription.deleted": {
402
+ const subscription = event.data.object;
403
+ const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id;
404
+ return {
405
+ ok: true,
406
+ status: 200,
407
+ eventType: event.type,
408
+ subscriptionId: subscription.id,
409
+ customerId,
410
+ subscriptionStatus: subscription.status
411
+ };
412
+ }
413
+
414
+ case "invoice.paid":
415
+ case "invoice.payment_failed": {
416
+ const invoice = event.data.object;
417
+ const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
418
+ return {
419
+ ok: true,
420
+ status: 200,
421
+ eventType: event.type,
422
+ invoiceId: invoice.id,
423
+ customerId,
424
+ paid: event.type === "invoice.paid"
425
+ };
426
+ }
427
+
428
+ // 3. Risk-side Events (Disputes, Refunds, Early Fraud Warnings) resolved via Stripe Object Graph
429
+ case "charge.dispute.created":
430
+ case "charge.refunded":
431
+ case "radar.early_fraud_warning.created": {
432
+ const obj = event.data.object;
433
+ const chargeId = event.type === "charge.refunded" ? obj.id : obj.charge;
434
+ let resolvedCustomerId = null;
435
+ let resolvedPaymentIntentId = null;
436
+
437
+ if (chargeId && typeof chargeId === "string") {
438
+ const charge = await stripe.charges.retrieve(chargeId, { expand: ["payment_intent"] });
439
+ resolvedCustomerId = typeof charge.customer === "string" ? charge.customer : charge.customer?.id || null;
440
+ resolvedPaymentIntentId =
441
+ typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id || null;
442
+ }
443
+
444
+ return {
445
+ ok: true,
446
+ status: 200,
447
+ eventType: event.type,
448
+ riskAction: "revoke_or_flag_entitlement",
449
+ chargeId,
450
+ customerId: resolvedCustomerId,
451
+ paymentIntentId: resolvedPaymentIntentId
452
+ };
453
+ }
454
+
455
+ default:
456
+ return { ok: true, status: 200, eventType: event.type, ignored: true };
457
+ }
458
+ }
459
+
460
+ export async function configureStripeBrandingAndPortal(iconPngBuffer) {
461
+ const stripe = getStripeClient();
462
+ if (!stripe) {
463
+ return { configured: false, reason: "missing_stripe_key" };
464
+ }
465
+
466
+ const results = {
467
+ iconFileId: null,
468
+ accountBrandingUpdated: false,
469
+ portalConfigured: false,
470
+ portalConfigurationId: null,
471
+ notes: []
472
+ };
473
+
474
+ if (iconPngBuffer && iconPngBuffer.length > 0) {
475
+ try {
476
+ const uploadedIcon = await stripe.files.create({
477
+ purpose: "business_icon",
478
+ file: {
479
+ data: iconPngBuffer,
480
+ name: "postbuild-icon.png",
481
+ type: "application/octet-stream"
482
+ }
483
+ });
484
+ results.iconFileId = uploadedIcon.id;
485
+ } catch (err) {
486
+ results.notes.push(`icon_upload: ${err.message}`);
487
+ }
488
+ }
489
+
490
+ try {
491
+ const account = await stripe.accounts.retrieve();
492
+ if (account && account.id) {
493
+ const brandingPayload = {
494
+ primary_color: "#0f172a",
495
+ secondary_color: "#10b981"
496
+ };
497
+ if (results.iconFileId) {
498
+ brandingPayload.icon = results.iconFileId;
499
+ brandingPayload.logo = results.iconFileId;
500
+ }
501
+ await stripe.accounts.update(account.id, {
502
+ settings: {
503
+ branding: brandingPayload
504
+ },
505
+ business_profile: {
506
+ name: "postbuild",
507
+ url: "https://postbuild.dev",
508
+ product_description:
509
+ "postbuild (postbuild.dev) is a developer SaaS platform and CLI tool (git paid / npx postbuild) that automates post-build technical SEO audits, structured metadata generation, programmatic SEO route packs, and curated SaaS directory launch kits."
510
+ }
511
+ });
512
+ results.accountBrandingUpdated = true;
513
+ }
514
+ } catch (err) {
515
+ results.notes.push(`account_update: ${err.message}`);
516
+ }
517
+
518
+ try {
519
+ const existingList = await stripe.billingPortal.configurations.list({ limit: 5 });
520
+ const portalPayload = {
521
+ business_profile: {
522
+ headline: "postbuild — Billing, Subscriptions & Tax Receipts",
523
+ privacy_policy_url: "https://postbuild.dev/privacy",
524
+ terms_of_service_url: "https://postbuild.dev/terms"
525
+ },
526
+ features: {
527
+ invoice_history: { enabled: true },
528
+ payment_method_update: { enabled: true },
529
+ subscription_cancel: {
530
+ enabled: true,
531
+ mode: "at_period_end"
532
+ }
533
+ }
534
+ };
535
+
536
+ if (existingList.data && existingList.data.length > 0) {
537
+ const updated = await stripe.billingPortal.configurations.update(existingList.data[0].id, portalPayload);
538
+ results.portalConfigured = true;
539
+ results.portalConfigurationId = updated.id;
540
+ } else {
541
+ const created = await stripe.billingPortal.configurations.create(portalPayload);
542
+ results.portalConfigured = true;
543
+ results.portalConfigurationId = created.id;
544
+ }
545
+ } catch (err) {
546
+ results.notes.push(`portal_config: ${err.message}`);
547
+ }
548
+
549
+ return {
550
+ configured: true,
551
+ ...results
552
+ };
553
+ }
554
+