Files
the-pet-loft/convex/stripeActions.ts
ianshaloom 3d50cb895c feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
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>
2026-03-07 17:59:29 +03:00

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 };
},
});