Files
the-pet-loft/convex/model/checkout.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

258 lines
7.9 KiB
TypeScript

import type { Id } from "../_generated/dataModel";
import type { QueryCtx } from "../_generated/server";
// ─── Cart Item Issues (discriminated union) ─────────────────────────────────
export type CartItemIssue =
| { type: "out_of_stock"; variantId: Id<"productVariants">; requested: number; available: number }
| { type: "insufficient_stock"; variantId: Id<"productVariants">; requested: number; available: number }
| { type: "variant_inactive"; variantId: Id<"productVariants"> }
| { type: "variant_not_found"; variantId: Id<"productVariants"> }
| { type: "product_not_found"; productId: Id<"products"> }
| { type: "price_changed"; variantId: Id<"productVariants">; oldPrice: number; newPrice: number };
// ─── Validated & Enriched Cart Item ─────────────────────────────────────────
export type ValidatedCartItem = {
variantId: Id<"productVariants">;
productId: Id<"products">;
quantity: number;
unitPrice: number;
originalPrice: number;
productName: string;
variantName: string;
sku: string;
imageUrl: string | undefined;
stockQuantity: number;
weight: number;
weightUnit: "g" | "kg" | "lb" | "oz";
length: number | undefined;
width: number | undefined;
height: number | undefined;
dimensionUnit: "cm" | "in" | undefined;
productSlug: string;
parentCategorySlug: string;
childCategorySlug: string;
};
// ─── Validation Result ──────────────────────────────────────────────────────
export type CartValidationResult = {
valid: boolean;
items: ValidatedCartItem[];
issues: CartItemIssue[];
subtotal: number;
};
// ─── Validation & Enrichment Helper ─────────────────────────────────────────
/**
* Validates every cart line item (stock, active status, price drift) and
* enriches each with weight/dimensions for downstream shipping-rate lookups.
*
* Price-change issues are warnings — they do NOT block checkout.
* Stock/missing/inactive issues ARE blocking.
*/
export async function validateAndEnrichCart(
ctx: Pick<QueryCtx, "db">,
items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[]
): Promise<CartValidationResult> {
const validatedItems: ValidatedCartItem[] = [];
const issues: CartItemIssue[] = [];
const blockingVariantIds = new Set<Id<"productVariants">>();
for (const item of items) {
if (!item.variantId) continue;
const variantId = item.variantId;
const variant = await ctx.db.get(variantId);
if (!variant) {
issues.push({ type: "variant_not_found" as const, variantId });
continue;
}
if (!variant.isActive) {
issues.push({ type: "variant_inactive" as const, variantId });
continue;
}
const product = await ctx.db.get(variant.productId);
if (!product || product.status !== "active") {
issues.push({ type: "product_not_found" as const, productId: item.productId });
continue;
}
if (variant.stockQuantity === 0) {
issues.push({
type: "out_of_stock" as const,
variantId,
requested: item.quantity,
available: 0,
});
blockingVariantIds.add(variantId);
} else if (variant.stockQuantity < item.quantity) {
issues.push({
type: "insufficient_stock" as const,
variantId,
requested: item.quantity,
available: variant.stockQuantity,
});
blockingVariantIds.add(variantId);
}
if (variant.price !== item.price) {
issues.push({
type: "price_changed" as const,
variantId,
oldPrice: item.price,
newPrice: variant.price,
});
}
const images = await ctx.db
.query("productImages")
.withIndex("by_product", (q) => q.eq("productId", variant.productId))
.collect();
images.sort((a, b) => a.position - b.position);
validatedItems.push({
variantId,
productId: variant.productId,
quantity: item.quantity,
unitPrice: variant.price,
originalPrice: item.price,
productName: product.name,
variantName: variant.name,
sku: variant.sku,
imageUrl: images[0]?.url,
stockQuantity: variant.stockQuantity,
weight: variant.weight,
weightUnit: variant.weightUnit,
length: variant.length,
width: variant.width,
height: variant.height,
dimensionUnit: variant.dimensionUnit,
productSlug: product.slug,
parentCategorySlug: product.parentCategorySlug,
childCategorySlug: product.childCategorySlug,
});
}
const blockingIssues = issues.filter(i => i.type !== "price_changed");
const valid = blockingIssues.length === 0;
const subtotal = validatedItems
.filter(item => !blockingVariantIds.has(item.variantId))
.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
return { valid, items: validatedItems, issues, subtotal };
}
// ─── Shippo Shipping Rate Types ──────────────────────────────────────────────
export type ShippoRate = {
objectId: string;
provider: string;
servicelevelName: string;
servicelevelToken: string;
amount: string;
currency: string;
estimatedDays: number | null;
durationTerms: string;
arrivesBy: string | null;
carrierAccount: string;
};
export type SelectedShippingRate = {
provider: string;
serviceName: string;
serviceToken: string;
amount: number;
currency: string;
estimatedDays: number | null;
durationTerms: string;
carrierAccount: string;
};
export type ShippingRateResult = {
shipmentObjectId: string;
selectedRate: SelectedShippingRate;
alternativeRates: SelectedShippingRate[];
cartSubtotal: number;
shippingTotal: number;
orderTotal: number;
};
// ─── Checkout Session Types (Stripe) ─────────────────────────────────────────
export type CreateCheckoutSessionInput = {
addressId: Id<"addresses">;
shipmentObjectId: string;
shippingRate: {
provider: string;
serviceName: string;
serviceToken: string;
amount: number;
currency: string;
estimatedDays: number | null;
durationTerms: string;
carrierAccount: string;
};
sessionId: string | undefined;
};
export type CheckoutSessionResult = {
clientSecret: string;
};
export type CheckoutSessionStatus = {
status: "complete" | "expired" | "open";
paymentStatus: string;
customerEmail: string | null;
};
// ─── Shippo Address Validation Types ─────────────────────────────────────────
export type ShippoConfidenceScore = "high" | "medium" | "low";
export type ShippoValidationValue = "valid" | "partially_valid" | "invalid";
export type ShippoAddressType =
| "residential"
| "commercial"
| "unknown"
| "po_box"
| "military";
export type ShippoValidationReason = {
code: string;
description: string;
};
export type RecommendedAddress = {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
completeAddress?: string;
confidenceScore: ShippoConfidenceScore;
confidenceCode: string;
confidenceDescription: string;
};
export type AddressValidationResult = {
isValid: boolean;
validationValue: ShippoValidationValue;
reasons: ShippoValidationReason[];
addressType: ShippoAddressType;
changedAttributes: string[];
recommendedAddress?: RecommendedAddress;
originalAddress: {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
};
};