Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions .eslintrc.json

This file was deleted.

192 changes: 192 additions & 0 deletions app/api/report-access/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import Stripe from 'stripe';
import { isPaidReportType, PAID_REPORT_TYPES, PaidReportType } from '@/lib/report-access';
import { verifyWalletAuth } from '@/lib/dynamic-auth';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
apiVersion: '2025-02-24.acacia',
});

function validateWalletAddress(walletAddress: unknown): string | null {
if (typeof walletAddress !== 'string') return null;
if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) return null;
return walletAddress.toLowerCase();
}

function stripeSearchValue(value: string) {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}

function isReportPayment(payment: Stripe.PaymentIntent, walletAddress: string) {
return (
payment.status === 'succeeded' &&
payment.metadata.purpose === 'report_one_time' &&
payment.metadata.walletAddress === walletAddress
);
}

async function findUnusedReportPayments(walletAddress: string, reportType?: PaidReportType) {
const clauses = [
`metadata['walletAddress']:'${stripeSearchValue(walletAddress)}'`,
`metadata['purpose']:'report_one_time'`,
`status:'succeeded'`,
];

if (reportType) {
clauses.push(`metadata['reportType']:'${stripeSearchValue(reportType)}'`);
}

const result = await stripe.paymentIntents.search({
query: clauses.join(' AND '),
limit: 100,
});

return result.data.filter(payment => !payment.metadata.consumedAt);
}

// Stripe search is eventually consistent and can lag behind a checkout that
// just completed. When the client passes the checkout session id from the
// success redirect, resolve the payment intent directly so the new pass is
// visible immediately.
async function findPaymentFromCheckoutSession(sessionId: string, walletAddress: string) {
try {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['payment_intent'],
});

if (session.payment_status !== 'paid') return null;

const payment = session.payment_intent;
if (!payment || typeof payment === 'string') return null;
if (!isReportPayment(payment, walletAddress)) return null;
if (payment.metadata.consumedAt) return null;

return payment;
} catch (error) {
console.error('Failed to resolve checkout session:', error);
return null;
}
}

async function collectUnusedPayments(
walletAddress: string,
sessionId: string | undefined,
reportType?: PaidReportType
) {
const payments = await findUnusedReportPayments(walletAddress, reportType);

if (typeof sessionId === 'string' && sessionId.startsWith('cs_')) {
const sessionPayment = await findPaymentFromCheckoutSession(sessionId, walletAddress);
if (
sessionPayment &&
(!reportType || sessionPayment.metadata.reportType === reportType) &&
!payments.some(payment => payment.id === sessionPayment.id)
) {
payments.push(sessionPayment);
}
}

return payments;
}

// Consumes one pass with an optimistic concurrency guard. Stripe metadata
// updates are last-writer-wins, so after stamping the payment intent we read
// it back and only treat the consumption as ours when our token survived.
async function consumeOnePayment(payments: Stripe.PaymentIntent[]) {
const candidates = [...payments].sort((a, b) => a.created - b.created);

for (const candidate of candidates) {
const fresh = await stripe.paymentIntents.retrieve(candidate.id);
if (fresh.metadata.consumedAt) continue;

const consumeToken = randomUUID();
await stripe.paymentIntents.update(candidate.id, {
metadata: {
...fresh.metadata,
consumedAt: new Date().toISOString(),
consumeToken,
},
});

const verified = await stripe.paymentIntents.retrieve(candidate.id);
if (verified.metadata.consumeToken === consumeToken) {
return candidate.id;
}
}

return null;
}

export async function POST(request: NextRequest) {
try {
const { walletAddress, reportType, action = 'check', sessionId } =
await request.json();
const normalizedWallet = validateWalletAddress(walletAddress);

if (!normalizedWallet) {
return NextResponse.json({ error: 'Invalid wallet address format' }, { status: 400 });
}

if (!process.env.STRIPE_SECRET_KEY) {
return NextResponse.json({ error: 'Stripe is not configured' }, { status: 500 });
}

if (action === 'check') {
const payments = await collectUnusedPayments(normalizedWallet, sessionId);
const availableReports = Object.fromEntries(
PAID_REPORT_TYPES.map(type => [
type,
payments.filter(payment => payment.metadata.reportType === type).length,
])
);

return NextResponse.json({
success: true,
availableReports,
});
}

// Consuming passes mutates paid state, so it requires proof that the
// caller owns the wallet.
if (action === 'consume') {
const auth = await verifyWalletAuth(request.headers.get('authorization'), normalizedWallet);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status || 401 });
}
}

if (action === 'consume') {
if (!isPaidReportType(reportType)) {
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 });
}

const payments = await collectUnusedPayments(normalizedWallet, sessionId, reportType);
const consumedPaymentIntentId = await consumeOnePayment(payments);

if (!consumedPaymentIntentId) {
return NextResponse.json(
{ success: false, error: 'No unused report pass found' },
{ status: 402 }
);
}

return NextResponse.json({
success: true,
consumedPaymentIntentId,
});
}

return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
} catch (error: any) {

Check warning on line 181 in app/api/report-access/route.ts

View workflow job for this annotation

GitHub Actions / build (20.x)

Unexpected any. Specify a different type
console.error('Report access check failed:', error);

return NextResponse.json(
{
error: 'Failed to check report access',
details: error.message || 'Unknown error',
},
{ status: 500 }
);
}
}
112 changes: 112 additions & 0 deletions app/api/stripe/create-report-checkout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import {
isPaidReportType,
ONE_TIME_REPORT_PRICE_CENTS,
PAID_REPORT_LABELS,
} from '@/lib/report-access';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', {
apiVersion: '2025-02-24.acacia',
});

async function getOrCreateCustomer(walletAddress: string) {
const normalizedWallet = walletAddress.toLowerCase();
const existingCustomers = await stripe.customers.search({
query: `metadata['walletAddress']:'${normalizedWallet}'`,
limit: 1,
});

if (existingCustomers.data[0]) {
return existingCustomers.data[0];
}

return stripe.customers.create({
metadata: {
walletAddress: normalizedWallet,
},
});
}

export async function POST(request: NextRequest) {
try {
const { walletAddress, reportType } = await request.json();

if (!walletAddress) {
return NextResponse.json({ error: 'Wallet address required' }, { status: 400 });
}

if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
return NextResponse.json({ error: 'Invalid wallet address format' }, { status: 400 });
}

if (!isPaidReportType(reportType)) {
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 });
}

if (!process.env.STRIPE_SECRET_KEY) {
return NextResponse.json({ error: 'Stripe is not configured' }, { status: 500 });
}

const requestOrigin = request.headers.get('origin');
const forwardedProto = request.headers.get('x-forwarded-proto') || 'http';
const forwardedHost = request.headers.get('x-forwarded-host') || request.headers.get('host');
const origin =
requestOrigin ||
process.env.NEXT_PUBLIC_APP_URL ||
(forwardedHost ? `${forwardedProto}://${forwardedHost}` : 'http://localhost:3001');

const normalizedWallet = walletAddress.toLowerCase();
const customer = await getOrCreateCustomer(walletAddress);
const reportLabel = PAID_REPORT_LABELS[reportType];

const metadata = {
walletAddress: normalizedWallet,
reportType,
purpose: 'report_one_time',
};

const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer: customer.id,
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: ONE_TIME_REPORT_PRICE_CENTS,
product_data: {
name: `${reportLabel} run`,
description: 'One-time access to generate this premium report once.',
},
},
quantity: 1,
},
],
success_url: `${origin}/overview-report?report_purchase=success&report_type=${reportType}&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/overview-report?report_purchase=cancelled&report_type=${reportType}`,
metadata,
payment_intent_data: {
metadata,
},
});

console.log(`[Stripe] Created one-time report checkout session: ${session.id} for ${reportType}`);

return NextResponse.json({
success: true,
sessionId: session.id,
checkoutUrl: session.url,
});
} catch (error: any) {
console.error('Stripe report checkout creation error:', error);

return NextResponse.json(
{
error: 'Failed to create report checkout session',
details: error.message || 'Unknown error',
},
{ status: 500 }
);
}
}
9 changes: 4 additions & 5 deletions app/api/stripe/create-subscription/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ export async function POST(request: NextRequest) {
price: process.env.STRIPE_PRICE_ID,
},
],
trial_period_days: 7,
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
Expand All @@ -111,8 +110,8 @@ export async function POST(request: NextRequest) {
metadata: {
walletAddress: walletAddress.toLowerCase(),
},
// Always require payment method collection, even if first invoice is $0
// This ensures we can charge after promotional period ends
// Always require payment method collection, even if the first invoice is $0.
// This ensures future subscription renewals can be charged.
collection_method: 'charge_automatically',
};

Expand Down Expand Up @@ -158,8 +157,8 @@ export async function POST(request: NextRequest) {

const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent | null;

// If there's no payment intent (e.g., $0 invoice due to 100% discount)
// We need to create a SetupIntent to collect payment method for future charges
// If there is no payment intent, for example a $0 invoice from a 100%
// discount, collect a payment method for future charges.
if (!paymentIntent) {
console.log(`[Stripe] No payment intent for $0 invoice - creating SetupIntent to collect payment method`);

Expand Down
Loading
Loading