Skip to content

Commit c7941bf

Browse files
author
Miriad
committed
feat: polish Phase 1d — Stripe integration, form bridge, email fix
1 parent d4cf875 commit c7941bf

File tree

6 files changed

+315
-69
lines changed

6 files changed

+315
-69
lines changed

app/api/sponsorship/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Resend } from "resend";
66
import { EmailTemplate } from "./sponsorship-template";
77
import { formSchema } from "@/lib/sponsorship-schema";
88
import { render } from "@react-email/render";
9+
import { extractSponsorIntent } from "@/lib/sponsor/gemini-intent";
10+
import { sanityWriteClient as pipelineClient } from "@/lib/sanity-write-client";
911

1012
const sanityWriteClient = createClient({
1113
projectId,
@@ -112,6 +114,30 @@ export async function POST(request: Request) {
112114
);
113115
}
114116

117+
// Also create a sponsorLead for the automated pipeline
118+
try {
119+
const intent = await extractSponsorIntent(
120+
`Company: ${companyName || "Unknown"}\nFrom: ${fullName} (${email})\nTiers: ${sponsorshipTier.join(", ")}\n${message || ""}`,
121+
);
122+
123+
await pipelineClient.create({
124+
_type: "sponsorLead",
125+
companyName: intent.companyName || companyName || "Unknown",
126+
contactName: intent.contactName || fullName,
127+
contactEmail: email,
128+
source: "inbound",
129+
status: "new",
130+
intent: intent.intent,
131+
rateCard: sponsorshipTier.join(", "),
132+
threadId: crypto.randomUUID(),
133+
lastEmailAt: new Date().toISOString(),
134+
});
135+
console.log("[SPONSOR] Created sponsorLead from form submission");
136+
} catch (error) {
137+
// Don't fail the form submission if pipeline creation fails
138+
console.error("[SPONSOR] Failed to create sponsorLead from form:", error);
139+
}
140+
115141
try {
116142
const resendApiKey = process.env.RESEND_SPONSORSHIP_API_KEY;
117143
if (resendApiKey) {

app/api/webhooks/stripe-sponsor/route.ts

Lines changed: 131 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,149 @@
11
import { NextResponse } from 'next/server'
2+
import Stripe from 'stripe'
3+
import { sanityWriteClient } from '@/lib/sanity-write-client'
24

35
/**
46
* Stripe webhook handler for sponsor invoices.
57
*
6-
* STUBBED — needs STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET
7-
*
8-
* Will handle:
8+
* Handles:
99
* - invoice.paid → update sponsorLead status to 'paid', assign to next video
10-
* - invoice.payment_failed → update sponsorLead stripePaymentStatus to 'failed'
11-
*
12-
* TODO: Wire up Stripe webhook verification and event handling
10+
* - invoice.payment_failed → update sponsorLead status back to 'negotiating'
1311
*/
12+
13+
/** Lazy Stripe client — only created when actually needed */
14+
let _stripe: Stripe | null = null
15+
function getStripeClient(): Stripe {
16+
if (!_stripe) {
17+
if (!process.env.STRIPE_SECRET_KEY) {
18+
throw new Error('STRIPE_SECRET_KEY not set')
19+
}
20+
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
21+
}
22+
return _stripe
23+
}
24+
1425
export async function POST(request: Request) {
1526
try {
1627
const body = await request.text()
28+
const sig = request.headers.get('stripe-signature')
1729

18-
// TODO: Verify Stripe webhook signature
19-
// const sig = request.headers.get('stripe-signature')
20-
// const event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!)
30+
if (!sig) {
31+
console.error('[SPONSOR] Missing stripe-signature header')
32+
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
33+
}
2134

22-
console.log('[SPONSOR] Stripe webhook received (stubbed):', {
23-
contentLength: body.length,
35+
if (!process.env.STRIPE_WEBHOOK_SECRET) {
36+
console.error('[SPONSOR] STRIPE_WEBHOOK_SECRET not set')
37+
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
38+
}
39+
40+
// Verify webhook signature
41+
const stripe = getStripeClient()
42+
let event: Stripe.Event
43+
try {
44+
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET)
45+
} catch (err) {
46+
console.error('[SPONSOR] Webhook signature verification failed:', err)
47+
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
48+
}
49+
50+
console.log('[SPONSOR] Stripe webhook received:', {
51+
type: event.type,
52+
id: event.id,
2453
timestamp: new Date().toISOString(),
2554
})
2655

27-
// TODO: Handle events
28-
// switch (event.type) {
29-
// case 'invoice.paid': {
30-
// const invoice = event.data.object
31-
// // Find sponsorLead by stripeInvoiceId
32-
// // Update status to 'paid'
33-
// // Update stripePaymentStatus to 'paid'
34-
// // Assign to next available video
35-
// break
36-
// }
37-
// case 'invoice.payment_failed': {
38-
// const invoice = event.data.object
39-
// // Find sponsorLead by stripeInvoiceId
40-
// // Update stripePaymentStatus to 'failed'
41-
// break
42-
// }
43-
// }
56+
switch (event.type) {
57+
case 'invoice.paid': {
58+
const invoice = event.data.object as Stripe.Invoice
59+
console.log('[SPONSOR] Invoice paid:', invoice.id)
60+
61+
// Find the sponsorLead by stripeInvoiceId in Sanity
62+
const lead = await sanityWriteClient.fetch(
63+
`*[_type == "sponsorLead" && stripeInvoiceId == $invoiceId][0]`,
64+
{ invoiceId: invoice.id }
65+
)
66+
67+
if (!lead) {
68+
console.warn('[SPONSOR] No sponsorLead found for invoice:', invoice.id)
69+
break
70+
}
71+
72+
// Update status to 'paid'
73+
await sanityWriteClient
74+
.patch(lead._id)
75+
.set({
76+
status: 'paid',
77+
stripePaymentStatus: 'paid',
78+
})
79+
.commit()
80+
81+
console.log('[SPONSOR] Updated sponsorLead to paid:', lead._id)
82+
83+
// Find next available automatedVideo (status script_ready or later, no sponsorSlot assigned)
84+
const availableVideo = await sanityWriteClient.fetch(
85+
`*[_type == "automatedVideo" && status in ["script_ready", "media_ready", "ready_to_publish"] && !defined(bookedSlot)][0]{
86+
_id,
87+
title,
88+
status
89+
}`
90+
)
91+
92+
if (availableVideo) {
93+
// Assign the lead to the video via bookedSlot
94+
await sanityWriteClient
95+
.patch(availableVideo._id)
96+
.set({
97+
bookedSlot: {
98+
_type: 'reference',
99+
_ref: lead._id,
100+
},
101+
})
102+
.commit()
103+
104+
console.log('[SPONSOR] Assigned lead to video:', {
105+
leadId: lead._id,
106+
videoId: availableVideo._id,
107+
videoTitle: availableVideo.title,
108+
})
109+
} else {
110+
console.warn('[SPONSOR] No available video found for lead:', lead._id)
111+
}
112+
113+
break
114+
}
115+
116+
case 'invoice.payment_failed': {
117+
const invoice = event.data.object as Stripe.Invoice
118+
console.log('[SPONSOR] Invoice payment failed:', invoice.id)
119+
120+
// Find the sponsorLead by stripeInvoiceId in Sanity
121+
const lead = await sanityWriteClient.fetch(
122+
`*[_type == "sponsorLead" && stripeInvoiceId == $invoiceId][0]`,
123+
{ invoiceId: invoice.id }
124+
)
125+
126+
if (!lead) {
127+
console.warn('[SPONSOR] No sponsorLead found for failed invoice:', invoice.id)
128+
break
129+
}
130+
131+
// Update sponsorLead status back to 'negotiating'
132+
await sanityWriteClient
133+
.patch(lead._id)
134+
.set({
135+
status: 'negotiating',
136+
stripePaymentStatus: 'failed',
137+
})
138+
.commit()
139+
140+
console.log('[SPONSOR] Updated sponsorLead to negotiating (payment failed):', lead._id)
141+
break
142+
}
143+
144+
default:
145+
console.log('[SPONSOR] Unhandled webhook event type:', event.type)
146+
}
44147

45148
return NextResponse.json({ received: true })
46149
} catch (error) {

lib/sponsor/email-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function sendSponsorEmail(
2727
}
2828

2929
try {
30-
const resend = new Resend(process.env.RESEND_API_KEY)
30+
const resend = getResendClient()
3131
const { data, error } = await resend.emails.send({
3232
from: FROM_EMAIL,
3333
to: [to],

lib/sponsor/stripe-service.ts

Lines changed: 87 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
22
* Stripe service for sponsor invoicing.
3-
*
4-
* STUBBED — needs STRIPE_SECRET_KEY
5-
* TODO: Wire up Stripe when API key is available
3+
*
4+
* Uses lazy initialization to avoid build-time crashes on Vercel.
5+
* Falls back to mock data if STRIPE_SECRET_KEY is not set (dev environments).
66
*/
77

8+
import Stripe from 'stripe'
9+
810
export interface SponsorLeadForInvoice {
911
_id: string
1012
companyName: string
@@ -17,57 +19,102 @@ export interface InvoiceResult {
1719
invoiceUrl: string
1820
}
1921

22+
/** Lazy Stripe client — only created when actually needed */
23+
let _stripe: Stripe | null = null
24+
function getStripeClient(): Stripe {
25+
if (!_stripe) {
26+
if (!process.env.STRIPE_SECRET_KEY) {
27+
throw new Error('STRIPE_SECRET_KEY not set')
28+
}
29+
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
30+
}
31+
return _stripe
32+
}
33+
2034
/**
2135
* Create a Stripe invoice for a sponsor deal.
22-
* Currently stubbed — logs and returns mock data.
36+
* If STRIPE_SECRET_KEY is not set, logs and returns mock data (for dev environments).
2337
*/
2438
export async function createSponsorInvoice(
2539
lead: SponsorLeadForInvoice,
2640
amount: number,
2741
description: string
2842
): Promise<InvoiceResult> {
29-
// TODO: Wire up Stripe when STRIPE_SECRET_KEY is available
30-
// import Stripe from 'stripe'
31-
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
32-
//
33-
// // Create or find customer
34-
// const customer = await stripe.customers.create({
35-
// email: lead.contactEmail,
36-
// name: lead.contactName,
37-
// metadata: { companyName: lead.companyName, sanityLeadId: lead._id },
38-
// })
39-
//
40-
// // Create invoice
41-
// const invoice = await stripe.invoices.create({
42-
// customer: customer.id,
43-
// collection_method: 'send_invoice',
44-
// days_until_due: 30,
45-
// })
46-
//
47-
// // Add line item
48-
// await stripe.invoiceItems.create({
49-
// customer: customer.id,
50-
// invoice: invoice.id,
51-
// amount,
52-
// currency: 'usd',
53-
// description,
54-
// })
55-
//
56-
// // Finalize and send
57-
// const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id!)
58-
// await stripe.invoices.sendInvoice(invoice.id!)
43+
// Fallback for dev environments without Stripe key
44+
if (!process.env.STRIPE_SECRET_KEY) {
45+
console.log('[SPONSOR] STRIPE_SECRET_KEY not set — returning mock invoice:', {
46+
leadId: lead._id,
47+
company: lead.companyName,
48+
amount,
49+
description,
50+
timestamp: new Date().toISOString(),
51+
})
52+
53+
const mockId = `inv_stub_${Date.now()}`
54+
return {
55+
invoiceId: mockId,
56+
invoiceUrl: `https://invoice.stripe.com/i/${mockId}`,
57+
}
58+
}
59+
60+
const stripe = getStripeClient()
61+
62+
// Create or find customer by email
63+
const existingCustomers = await stripe.customers.list({
64+
email: lead.contactEmail,
65+
limit: 1,
66+
})
5967

60-
console.log('[SPONSOR] Invoice creation (stubbed):', {
68+
let customer: Stripe.Customer
69+
if (existingCustomers.data.length > 0) {
70+
customer = existingCustomers.data[0]
71+
console.log('[SPONSOR] Found existing Stripe customer:', customer.id)
72+
} else {
73+
customer = await stripe.customers.create({
74+
email: lead.contactEmail,
75+
name: lead.contactName,
76+
metadata: {
77+
companyName: lead.companyName,
78+
sanityLeadId: lead._id,
79+
},
80+
})
81+
console.log('[SPONSOR] Created new Stripe customer:', customer.id)
82+
}
83+
84+
// Create invoice with 30-day terms
85+
const invoice = await stripe.invoices.create({
86+
customer: customer.id,
87+
collection_method: 'send_invoice',
88+
days_until_due: 30,
89+
metadata: {
90+
sanityLeadId: lead._id,
91+
companyName: lead.companyName,
92+
},
93+
})
94+
95+
// Add line item with amount and description
96+
await stripe.invoiceItems.create({
97+
customer: customer.id,
98+
invoice: invoice.id,
99+
amount, // amount in cents
100+
currency: 'usd',
101+
description,
102+
})
103+
104+
// Finalize and send invoice
105+
const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id!)
106+
await stripe.invoices.sendInvoice(invoice.id!)
107+
108+
console.log('[SPONSOR] Invoice created and sent:', {
109+
invoiceId: finalizedInvoice.id,
110+
invoiceUrl: finalizedInvoice.hosted_invoice_url,
61111
leadId: lead._id,
62112
company: lead.companyName,
63113
amount,
64-
description,
65-
timestamp: new Date().toISOString(),
66114
})
67115

68-
const mockId = `inv_stub_${Date.now()}`
69116
return {
70-
invoiceId: mockId,
71-
invoiceUrl: `https://invoice.stripe.com/i/${mockId}`,
117+
invoiceId: finalizedInvoice.id,
118+
invoiceUrl: finalizedInvoice.hosted_invoice_url || `https://invoice.stripe.com/i/${finalizedInvoice.id}`,
72119
}
73120
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"sanity-plugin-cloudinary": "^1.4.1",
108108
"server-only": "^0.0.1",
109109
"sonner": "^2.0.7",
110+
"stripe": "^20.4.0",
110111
"styled-components": "^6.1.19",
111112
"tailwind-merge": "^3.5.0",
112113
"tailwindcss-animate": "^1.0.7",

0 commit comments

Comments
 (0)