Files
the-pet-loft/convex/checkoutActions.ts
ianshaloom cc15338ad9 feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:31:18 +03:00

137 lines
3.9 KiB
TypeScript

"use node";
import { action } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import {
validateAddressWithShippo,
computeParcel,
getShippingRatesFromShippo,
selectBestRate,
MAX_PARCEL_WEIGHT_G,
} from "./model/shippo";
import type { ShippingRateResult } from "./model/checkout";
export const validateAddress = action({
args: {
addressLine1: v.string(),
additionalInformation: v.optional(v.string()),
city: v.string(),
postalCode: v.string(),
country: v.string(),
name: v.optional(v.string()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("You must be signed in to validate an address.");
}
return await validateAddressWithShippo({
addressLine1: args.addressLine1,
additionalInformation: args.additionalInformation,
city: args.city,
postalCode: args.postalCode,
country: args.country,
name: args.name,
});
},
});
export const getShippingRate = action({
args: {
addressId: v.id("addresses"),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args): Promise<ShippingRateResult> => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("You must be signed in to get shipping rates.");
}
const address = await ctx.runQuery(internal.checkout.getAddressById, {
addressId: args.addressId,
});
if (address.isValidated !== true) {
console.warn(
`Shipping rate requested for unvalidated address ${args.addressId}. ` +
"The shipping address step should validate before proceeding.",
);
}
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
const cartResult = await ctx.runQuery(
internal.checkout.validateCartInternal,
{ userId, sessionId: args.sessionId },
);
if (!cartResult) {
throw new ConvexError("Your cart is empty.");
}
if (!cartResult.valid) {
throw new ConvexError(
"Your cart has issues that need to be resolved before checkout.",
);
}
const parcel = computeParcel(cartResult.items);
const weightG = parseFloat(parcel.weight);
if (weightG > MAX_PARCEL_WEIGHT_G) {
const actualKg = (weightG / 1000).toFixed(1);
const maxKg = MAX_PARCEL_WEIGHT_G / 1000;
throw new ConvexError(
`Your order weighs ${actualKg}kg, which exceeds our maximum shipping weight of ${maxKg}kg. ` +
"Please remove some items or reduce quantities to proceed.",
);
}
const shippoAddress = {
name: address.fullName,
street1: address.addressLine1,
street2: address.additionalInformation,
city: address.city,
zip: address.postalCode,
country: address.country,
phone: address.phone,
};
const sourceAddressId = process.env.SHIPPO_SOURCE_ADDRESS_ID;
if (!sourceAddressId) {
throw new ConvexError(
"Shipping configuration is incomplete (missing source address).",
);
}
const { shipmentObjectId, rates } = await getShippingRatesFromShippo({
sourceAddressId,
destinationAddress: shippoAddress,
parcels: [parcel],
});
const { selected, alternatives } = selectBestRate(rates);
const mapRate = (r: typeof selected) => ({
provider: r.provider,
serviceName: r.servicelevelName,
serviceToken: r.servicelevelToken,
amount: parseFloat(r.amount),
currency: r.currency,
estimatedDays: r.estimatedDays,
durationTerms: r.durationTerms,
carrierAccount: r.carrierAccount,
});
return {
shipmentObjectId,
selectedRate: mapRate(selected),
alternativeRates: alternatives.map(mapRate),
cartSubtotal: cartResult.subtotal,
shippingTotal: parseFloat(selected.amount),
orderTotal: cartResult.subtotal + parseFloat(selected.amount),
};
},
});