git-paid 2.1.2 → 2.1.4
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/README.md +22 -34
- package/bin/git-paid.js +127 -52
- package/lib/auth.js +866 -208
- package/lib/directories.js +123 -123
- package/lib/engine.js +228 -112
- package/lib/fixer.js +58 -0
- package/lib/inbox.js +359 -72
- package/lib/mcp.js +220 -87
- package/lib/pseo.js +290 -106
- package/lib/safe-fetch.js +239 -0
- package/lib/stateful-mcp.js +16 -4
- package/lib/submitter.js +89 -28
- package/package.json +53 -44
- package/brand/README.md +0 -54
- package/brand/postbuild-icon.png +0 -0
- package/brand/postbuild-icon.svg +0 -14
- package/brand/postbuild-logo.png +0 -0
- package/brand/postbuild-logo.svg +0 -17
- package/lib/stripe.js +0 -554
- package/server.js +0 -252
package/brand/postbuild-logo.svg
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="260" viewBox="0 0 1000 260" fill="none">
|
|
2
|
-
<rect width="1000" height="260" fill="#FFFFFF"/>
|
|
3
|
-
<g transform="translate(36, 26) scale(3.25)">
|
|
4
|
-
|
|
5
|
-
<!-- Top-left build block (git push source) -->
|
|
6
|
-
<rect x="7" y="7" width="20" height="20" rx="5.5" stroke="#0F172A" stroke-width="5" fill="#F8FAFC"/>
|
|
7
|
-
<!-- Bottom-left build block (git paid source) -->
|
|
8
|
-
<rect x="7" y="37" width="20" height="20" rx="5.5" stroke="#64748B" stroke-width="5" fill="#F8FAFC"/>
|
|
9
|
-
<!-- Top-right arrow (git push) -->
|
|
10
|
-
<path d="M36 17H56M56 17L47.5 8.5M56 17L47.5 25.5" stroke="#0284C7" stroke-width="5.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
11
|
-
<!-- Bottom-right emerald badge + crisp typographic $ (git paid) -->
|
|
12
|
-
<rect x="35" y="35" width="22" height="22" rx="6.5" fill="#10B981"/>
|
|
13
|
-
<text x="46" y="52.5" text-anchor="middle" fill="#FFFFFF" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif" font-size="18.5" font-weight="800">$</text>
|
|
14
|
-
|
|
15
|
-
</g>
|
|
16
|
-
<text x="276" y="176" fill="#0F172A" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif" font-size="144" font-weight="800" letter-spacing="-5.5">postbuild</text>
|
|
17
|
-
</svg>
|
package/lib/stripe.js
DELETED
|
@@ -1,554 +0,0 @@
|
|
|
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
|
-
|