This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
|
||||
import {
|
||||
getPaymentMethodDisplay,
|
||||
getStripeClient,
|
||||
getWebhookSecret,
|
||||
} from "@/lib/stripe";
|
||||
import {
|
||||
getInvoiceByStripePaymentIntent,
|
||||
getOrgIdByStripeCustomerId,
|
||||
isStripeRefundRecorded,
|
||||
markInvoicePaid,
|
||||
markStripeEventProcessed,
|
||||
setInvoiceStripePaymentIntent,
|
||||
setSavedPaymentMethod,
|
||||
tryRecordStripeEvent,
|
||||
} from "@/lib/db";
|
||||
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||
@@ -161,6 +167,14 @@ export async function POST(request: Request) {
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
// Phase 9: setup-mode sessions don't pay anything — they
|
||||
// authorize a card for off-session future charges. The
|
||||
// PaymentMethod is attached to the customer and the session's
|
||||
// setup_intent.payment_method holds the id we save.
|
||||
if (session.mode === "setup") {
|
||||
await handleSetupCompleted(session);
|
||||
return;
|
||||
}
|
||||
// Defensive: paid sessions are what we want; sessions can also
|
||||
// complete in "unpaid" state (rare for mode=payment, more common
|
||||
// for async/delayed methods like SEPA). Only flip the invoice
|
||||
@@ -211,6 +225,97 @@ async function handleCheckoutCompleted(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||
* authorized a card for future off-session charges; persist the
|
||||
* display fields against their org so the portal can show the
|
||||
* saved card and use it for auto-charge.
|
||||
*
|
||||
* The session carries:
|
||||
* - mode: 'setup'
|
||||
* - customer: 'cus_xxx' (the Stripe customer id we created)
|
||||
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
|
||||
*
|
||||
* We look up which org owns the customer (via
|
||||
* org_billing_config.stripe_customer_id), fetch the SetupIntent
|
||||
* to find the resulting PaymentMethod id, then fetch the PM for
|
||||
* its display fields. Three Stripe round-trips total — acceptable
|
||||
* for a one-off setup event.
|
||||
*/
|
||||
async function handleSetupCompleted(
|
||||
session: Stripe.Checkout.Session
|
||||
): Promise<void> {
|
||||
const customerId =
|
||||
typeof session.customer === "string"
|
||||
? session.customer
|
||||
: session.customer?.id;
|
||||
if (!customerId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a customer; cannot link to org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||
if (!orgId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} for customer ${customerId} has no matching org.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const setupIntentId =
|
||||
typeof session.setup_intent === "string"
|
||||
? session.setup_intent
|
||||
: session.setup_intent?.id;
|
||||
if (!setupIntentId) {
|
||||
console.error(
|
||||
`Setup session ${session.id} completed without a setup_intent id.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Read the SetupIntent for the resulting PaymentMethod id.
|
||||
const stripe = getStripeClient();
|
||||
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||
const paymentMethodId =
|
||||
typeof setupIntent.payment_method === "string"
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id;
|
||||
if (!paymentMethodId) {
|
||||
console.error(
|
||||
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fetch the PM details for display columns.
|
||||
const display = await getPaymentMethodDisplay(paymentMethodId);
|
||||
await setSavedPaymentMethod({
|
||||
zitadelOrgId: orgId,
|
||||
stripeCustomerId: customerId,
|
||||
paymentMethodId,
|
||||
brand: display.brand,
|
||||
last4: display.last4,
|
||||
expMonth: display.expMonth,
|
||||
expYear: display.expYear,
|
||||
});
|
||||
// Also tell Stripe this PM is the customer's default for invoice
|
||||
// payments — so a future stripe.paymentIntents.create against
|
||||
// this customer without an explicit payment_method picks it up.
|
||||
// Best-effort: a failure here doesn't undo the save (we have the
|
||||
// pm id, we can pass it explicitly when charging in Phase 9b).
|
||||
try {
|
||||
await stripe.customers.update(customerId, {
|
||||
invoice_settings: { default_payment_method: paymentMethodId },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||
|
||||
Reference in New Issue
Block a user