Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
"use node";
|
|
|
|
import Stripe from "stripe";
|
|
import { action, internalAction } from "./_generated/server";
|
|
import { internal } from "./_generated/api";
|
|
import { v } from "convex/values";
|
|
import { getOrCreateStripeCustomer } from "./model/stripe";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import type {
|
|
CheckoutSessionResult,
|
|
CheckoutSessionStatus,
|
|
CartValidationResult,
|
|
} from "./model/checkout";
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
|
|
export const createCheckoutSession = action({
|
|
args: {
|
|
addressId: v.id("addresses"),
|
|
shipmentObjectId: v.string(),
|
|
shippingRate: v.object({
|
|
provider: v.string(),
|
|
serviceName: v.string(),
|
|
serviceToken: v.string(),
|
|
amount: v.number(),
|
|
currency: v.string(),
|
|
estimatedDays: v.union(v.number(), v.null()),
|
|
durationTerms: v.string(),
|
|
carrierAccount: v.string(),
|
|
}),
|
|
sessionId: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<CheckoutSessionResult> => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new Error("You must be signed in to checkout.");
|
|
}
|
|
|
|
const userId: Id<"users"> = await ctx.runQuery(
|
|
internal.checkout.getCurrentUserId,
|
|
);
|
|
|
|
const user = await ctx.runQuery(internal.users.getById, { userId });
|
|
|
|
const stripeCustomerId = await getOrCreateStripeCustomer({
|
|
stripeCustomerId: user.stripeCustomerId,
|
|
email: user.email,
|
|
name: user.name,
|
|
convexUserId: userId,
|
|
});
|
|
|
|
if (!user.stripeCustomerId) {
|
|
await ctx.runMutation(internal.users.setStripeCustomerId, {
|
|
userId,
|
|
stripeCustomerId,
|
|
});
|
|
}
|
|
|
|
const address = await ctx.runQuery(internal.checkout.getAddressById, {
|
|
addressId: args.addressId,
|
|
});
|
|
if (address.userId !== userId) {
|
|
throw new Error("Address does not belong to the current user.");
|
|
}
|
|
|
|
const cartResult: CartValidationResult | null = await ctx.runQuery(
|
|
internal.checkout.validateCartInternal,
|
|
{ userId, sessionId: args.sessionId },
|
|
);
|
|
|
|
if (!cartResult) {
|
|
throw new Error("Your cart is empty.");
|
|
}
|
|
if (!cartResult.valid) {
|
|
throw new Error("Your cart has issues that need to be resolved.");
|
|
}
|
|
|
|
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] =
|
|
cartResult.items.map((item) => ({
|
|
price_data: {
|
|
currency: "gbp",
|
|
product_data: {
|
|
name: `${item.productName} — ${item.variantName}`,
|
|
},
|
|
unit_amount: Math.round(item.unitPrice),
|
|
},
|
|
quantity: item.quantity,
|
|
}));
|
|
|
|
const STOREFRONT_URL = process.env.STOREFRONT_URL;
|
|
if (!STOREFRONT_URL) {
|
|
throw new Error("STOREFRONT_URL environment variable is not set.");
|
|
}
|
|
|
|
const session: Stripe.Checkout.Session =
|
|
await stripe.checkout.sessions.create({
|
|
mode: "payment",
|
|
ui_mode: "custom",
|
|
customer: stripeCustomerId,
|
|
line_items: lineItems,
|
|
shipping_options: [
|
|
{
|
|
shipping_rate_data: {
|
|
type: "fixed_amount",
|
|
fixed_amount: {
|
|
amount: Math.round(args.shippingRate.amount * 100),
|
|
currency: "gbp",
|
|
},
|
|
display_name: `${args.shippingRate.provider} — ${args.shippingRate.serviceName}`,
|
|
...(args.shippingRate.estimatedDays != null && {
|
|
delivery_estimate: {
|
|
minimum: {
|
|
unit: "business_day" as const,
|
|
value: args.shippingRate.estimatedDays,
|
|
},
|
|
maximum: {
|
|
unit: "business_day" as const,
|
|
value: args.shippingRate.estimatedDays,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
return_url: `${STOREFRONT_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
metadata: {
|
|
convexUserId: userId,
|
|
addressId: args.addressId,
|
|
shipmentObjectId: args.shipmentObjectId,
|
|
shippingMethod: `${args.shippingRate.provider} — ${args.shippingRate.serviceName}`,
|
|
shippingServiceCode: args.shippingRate.serviceToken,
|
|
carrier: args.shippingRate.provider,
|
|
carrierAccount: args.shippingRate.carrierAccount,
|
|
},
|
|
});
|
|
|
|
if (!session.client_secret) {
|
|
throw new Error("Stripe session missing client_secret.");
|
|
}
|
|
|
|
return { clientSecret: session.client_secret };
|
|
},
|
|
});
|
|
|
|
export const getCheckoutSessionStatus = action({
|
|
args: {
|
|
sessionId: v.string(),
|
|
},
|
|
handler: async (_ctx, args): Promise<CheckoutSessionStatus> => {
|
|
const session: Stripe.Checkout.Session =
|
|
await stripe.checkout.sessions.retrieve(args.sessionId);
|
|
|
|
return {
|
|
status: session.status as "complete" | "expired" | "open",
|
|
paymentStatus: session.payment_status,
|
|
customerEmail: session.customer_details?.email ?? null,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const handleWebhook = internalAction({
|
|
args: {
|
|
payload: v.string(),
|
|
signature: v.string(),
|
|
},
|
|
handler: async (ctx, args): Promise<{ success: boolean }> => {
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
if (!webhookSecret) {
|
|
throw new Error("STRIPE_WEBHOOK_SECRET environment variable is not set.");
|
|
}
|
|
|
|
let event: Stripe.Event;
|
|
try {
|
|
event = stripe.webhooks.constructEvent(
|
|
args.payload,
|
|
args.signature,
|
|
webhookSecret,
|
|
);
|
|
} catch (err) {
|
|
console.error("Webhook signature verification failed:", err);
|
|
return { success: false };
|
|
}
|
|
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
case "checkout.session.async_payment_succeeded": {
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
const metadata = session.metadata;
|
|
|
|
if (
|
|
!metadata?.convexUserId ||
|
|
!metadata?.addressId ||
|
|
!metadata?.shipmentObjectId ||
|
|
!metadata?.shippingMethod ||
|
|
!metadata?.shippingServiceCode ||
|
|
!metadata?.carrier
|
|
) {
|
|
console.error(
|
|
"Missing required metadata on checkout session:",
|
|
session.id,
|
|
);
|
|
return { success: false };
|
|
}
|
|
|
|
await ctx.runMutation(internal.orders.fulfillFromCheckout, {
|
|
stripeCheckoutSessionId: session.id,
|
|
stripePaymentIntentId:
|
|
typeof session.payment_intent === "string"
|
|
? session.payment_intent
|
|
: session.payment_intent?.id ?? null,
|
|
convexUserId: metadata.convexUserId,
|
|
addressId: metadata.addressId,
|
|
shipmentObjectId: metadata.shipmentObjectId,
|
|
shippingMethod: metadata.shippingMethod,
|
|
shippingServiceCode: metadata.shippingServiceCode,
|
|
carrier: metadata.carrier,
|
|
amountTotal: session.amount_total,
|
|
amountShipping:
|
|
session.shipping_cost?.amount_total ??
|
|
session.total_details?.amount_shipping ??
|
|
0,
|
|
currency: session.currency,
|
|
});
|
|
break;
|
|
}
|
|
case "checkout.session.expired":
|
|
console.warn(
|
|
"Checkout session expired:",
|
|
(event.data.object as Stripe.Checkout.Session).id,
|
|
);
|
|
break;
|
|
case "refund.updated": {
|
|
const refund = event.data.object as Stripe.Refund;
|
|
if (refund.status === "succeeded" && refund.payment_intent) {
|
|
const paymentIntentId =
|
|
typeof refund.payment_intent === "string"
|
|
? refund.payment_intent
|
|
: refund.payment_intent.id;
|
|
const order = await ctx.runQuery(
|
|
internal.orders.getOrderByPaymentIntent,
|
|
{ stripePaymentIntentId: paymentIntentId },
|
|
);
|
|
if (order && order.paymentStatus !== "refunded") {
|
|
await ctx.runMutation(internal.orders.applyRefund, {
|
|
id: order._id,
|
|
adminUserId: order.userId,
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
console.log("Unhandled Stripe event type:", event.type);
|
|
}
|
|
|
|
return { success: true };
|
|
},
|
|
});
|