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>
This commit is contained in:
80
convex/model/carts.ts
Normal file
80
convex/model/carts.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
|
||||
const CART_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
type CartReadCtx = Pick<QueryCtx, "db">;
|
||||
|
||||
/**
|
||||
* Get cart by userId or sessionId (read-only). Returns null if not found.
|
||||
*/
|
||||
export async function getCart(
|
||||
ctx: CartReadCtx,
|
||||
userId?: Id<"users">,
|
||||
sessionId?: string
|
||||
) {
|
||||
if (userId) {
|
||||
return await ctx.db
|
||||
.query("carts")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.unique();
|
||||
}
|
||||
if (sessionId) {
|
||||
return await ctx.db
|
||||
.query("carts")
|
||||
.withIndex("by_session", (q) => q.eq("sessionId", sessionId))
|
||||
.unique();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing cart or create a new one. Mutation-only; use from addItem, updateItem, removeItem, clear, merge.
|
||||
* At least one of userId or sessionId must be provided.
|
||||
*/
|
||||
export async function getOrCreateCart(
|
||||
ctx: MutationCtx,
|
||||
userId?: Id<"users">,
|
||||
sessionId?: string
|
||||
): Promise<{ _id: Id<"carts">; items: { productId: Id<"products">; variantId?: Id<"productVariants">; quantity: number; price: number }[] }> {
|
||||
if (!userId && !sessionId) {
|
||||
throw new Error("Either userId or sessionId must be provided");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const expiresAt = now + CART_EXPIRY_MS;
|
||||
|
||||
if (userId) {
|
||||
const existing = await ctx.db
|
||||
.query("carts")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.unique();
|
||||
if (existing) return existing;
|
||||
const id = await ctx.db.insert("carts", {
|
||||
userId,
|
||||
items: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt,
|
||||
});
|
||||
const cart = await ctx.db.get(id);
|
||||
if (!cart) throw new Error("Failed to create cart");
|
||||
return cart;
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("carts")
|
||||
.withIndex("by_session", (q) => q.eq("sessionId", sessionId!))
|
||||
.unique();
|
||||
if (existing) return existing;
|
||||
const id = await ctx.db.insert("carts", {
|
||||
sessionId: sessionId!,
|
||||
items: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt,
|
||||
});
|
||||
const cart = await ctx.db.get(id);
|
||||
if (!cart) throw new Error("Failed to create cart");
|
||||
return cart;
|
||||
}
|
||||
11
convex/model/categories.ts
Normal file
11
convex/model/categories.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { QueryCtx } from "../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
|
||||
export async function getCategoryOrThrow(
|
||||
ctx: QueryCtx,
|
||||
id: Id<"categories">,
|
||||
) {
|
||||
const category = await ctx.db.get(id);
|
||||
if (!category) throw new Error("Category not found");
|
||||
return category;
|
||||
}
|
||||
127
convex/model/checkout.test.ts
Normal file
127
convex/model/checkout.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import type {
|
||||
ShippoConfidenceScore,
|
||||
ShippoValidationValue,
|
||||
ShippoAddressType,
|
||||
ShippoValidationReason,
|
||||
RecommendedAddress,
|
||||
AddressValidationResult,
|
||||
} from "./checkout";
|
||||
|
||||
describe("AddressValidationResult types", () => {
|
||||
it("ShippoConfidenceScore accepts valid literals", () => {
|
||||
expectTypeOf<"high">().toMatchTypeOf<ShippoConfidenceScore>();
|
||||
expectTypeOf<"medium">().toMatchTypeOf<ShippoConfidenceScore>();
|
||||
expectTypeOf<"low">().toMatchTypeOf<ShippoConfidenceScore>();
|
||||
});
|
||||
|
||||
it("ShippoValidationValue accepts valid literals", () => {
|
||||
expectTypeOf<"valid">().toMatchTypeOf<ShippoValidationValue>();
|
||||
expectTypeOf<"partially_valid">().toMatchTypeOf<ShippoValidationValue>();
|
||||
expectTypeOf<"invalid">().toMatchTypeOf<ShippoValidationValue>();
|
||||
});
|
||||
|
||||
it("ShippoAddressType accepts valid literals", () => {
|
||||
expectTypeOf<"residential">().toMatchTypeOf<ShippoAddressType>();
|
||||
expectTypeOf<"commercial">().toMatchTypeOf<ShippoAddressType>();
|
||||
expectTypeOf<"unknown">().toMatchTypeOf<ShippoAddressType>();
|
||||
expectTypeOf<"po_box">().toMatchTypeOf<ShippoAddressType>();
|
||||
expectTypeOf<"military">().toMatchTypeOf<ShippoAddressType>();
|
||||
});
|
||||
|
||||
it("ShippoValidationReason has code and description", () => {
|
||||
expectTypeOf<ShippoValidationReason>().toHaveProperty("code");
|
||||
expectTypeOf<ShippoValidationReason>().toHaveProperty("description");
|
||||
});
|
||||
|
||||
it("RecommendedAddress has required fields and no state", () => {
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("addressLine1");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("city");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("postalCode");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("country");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceScore");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceCode");
|
||||
expectTypeOf<RecommendedAddress>().toHaveProperty("confidenceDescription");
|
||||
|
||||
type Keys = keyof RecommendedAddress;
|
||||
expectTypeOf<"state" extends Keys ? true : false>().toEqualTypeOf<false>();
|
||||
expectTypeOf<"addressLine2" extends Keys ? true : false>().toEqualTypeOf<false>();
|
||||
});
|
||||
|
||||
it("AddressValidationResult is structurally complete", () => {
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("isValid");
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("validationValue");
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("reasons");
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("addressType");
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("changedAttributes");
|
||||
expectTypeOf<AddressValidationResult>().toHaveProperty("originalAddress");
|
||||
});
|
||||
|
||||
it("recommendedAddress is optional on AddressValidationResult", () => {
|
||||
const withoutRecommended: AddressValidationResult = {
|
||||
isValid: true,
|
||||
validationValue: "valid",
|
||||
reasons: [],
|
||||
addressType: "unknown",
|
||||
changedAttributes: [],
|
||||
originalAddress: {
|
||||
addressLine1: "10 Downing Street",
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
},
|
||||
};
|
||||
expect(withoutRecommended.recommendedAddress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("AddressValidationResult accepts a full object with recommended address", () => {
|
||||
const full: AddressValidationResult = {
|
||||
isValid: false,
|
||||
validationValue: "partially_valid",
|
||||
reasons: [{ code: "postal_data_match", description: "Postal code matched" }],
|
||||
addressType: "unknown",
|
||||
changedAttributes: ["postalCode"],
|
||||
recommendedAddress: {
|
||||
addressLine1: "10 Downing Street",
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
completeAddress: "10 Downing Street;LONDON;SW1A 2AA;UNITED KINGDOM",
|
||||
confidenceScore: "high",
|
||||
confidenceCode: "postal_data_match",
|
||||
confidenceDescription: "Matched via postal data",
|
||||
},
|
||||
originalAddress: {
|
||||
addressLine1: "10 Downing St",
|
||||
city: "London",
|
||||
postalCode: "SW1A2AA",
|
||||
country: "GB",
|
||||
},
|
||||
};
|
||||
expect(full.isValid).toBe(false);
|
||||
expect(full.recommendedAddress).toBeDefined();
|
||||
expect(full.recommendedAddress!.confidenceScore).toBe("high");
|
||||
});
|
||||
|
||||
it("originalAddress uses additionalInformation instead of addressLine2", () => {
|
||||
const result: AddressValidationResult = {
|
||||
isValid: true,
|
||||
validationValue: "valid",
|
||||
reasons: [],
|
||||
addressType: "unknown",
|
||||
changedAttributes: [],
|
||||
originalAddress: {
|
||||
addressLine1: "10 Downing Street",
|
||||
additionalInformation: "Flat 1",
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
},
|
||||
};
|
||||
expect(result.originalAddress.additionalInformation).toBe("Flat 1");
|
||||
|
||||
type OrigKeys = keyof AddressValidationResult["originalAddress"];
|
||||
expectTypeOf<"state" extends OrigKeys ? true : false>().toEqualTypeOf<false>();
|
||||
expectTypeOf<"addressLine2" extends OrigKeys ? true : false>().toEqualTypeOf<false>();
|
||||
});
|
||||
});
|
||||
257
convex/model/checkout.ts
Normal file
257
convex/model/checkout.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
85
convex/model/orders.ts
Normal file
85
convex/model/orders.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { QueryCtx } from "../_generated/server";
|
||||
import { Id, Doc } from "../_generated/dataModel";
|
||||
|
||||
export async function getOrderWithItems(
|
||||
ctx: QueryCtx,
|
||||
orderId: Id<"orders">,
|
||||
) {
|
||||
const order = await ctx.db.get(orderId);
|
||||
if (!order) return null;
|
||||
|
||||
const items = await ctx.db
|
||||
.query("orderItems")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", orderId))
|
||||
.collect();
|
||||
|
||||
return { ...order, items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a customer is allowed to cancel a given order.
|
||||
*
|
||||
* NOTE: Cancellation only updates order status and restores stock.
|
||||
* Stripe refund processing is a separate concern handled via the admin
|
||||
* dashboard or a future automated flow. This helper does NOT trigger a refund.
|
||||
*/
|
||||
export function canCustomerCancel(order: Doc<"orders">): {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
} {
|
||||
switch (order.status) {
|
||||
case "confirmed":
|
||||
return { allowed: true };
|
||||
case "pending":
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Order is still awaiting payment confirmation.",
|
||||
};
|
||||
case "cancelled":
|
||||
return { allowed: false, reason: "Order is already cancelled." };
|
||||
case "refunded":
|
||||
return { allowed: false, reason: "Order has already been refunded." };
|
||||
default:
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
"Order has progressed past the cancellation window. Please contact support.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OutOfStockItem {
|
||||
variantId: Id<"productVariants">;
|
||||
requested: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check each cart item for sufficient stock. Returns list of out-of-stock entries.
|
||||
*/
|
||||
export async function validateCartItems(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
items: { variantId?: Id<"productVariants">; quantity: number }[]
|
||||
): Promise<OutOfStockItem[]> {
|
||||
const outOfStock: OutOfStockItem[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.variantId) continue;
|
||||
const variant = await ctx.db.get(item.variantId);
|
||||
if (!variant) {
|
||||
outOfStock.push({
|
||||
variantId: item.variantId,
|
||||
requested: item.quantity,
|
||||
available: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (variant.stockQuantity < item.quantity) {
|
||||
outOfStock.push({
|
||||
variantId: item.variantId,
|
||||
requested: item.quantity,
|
||||
available: variant.stockQuantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
return outOfStock;
|
||||
}
|
||||
80
convex/model/products.ts
Normal file
80
convex/model/products.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
|
||||
/**
|
||||
* Recalculate product averageRating and reviewCount from approved reviews.
|
||||
* Call after review approve/delete.
|
||||
*/
|
||||
export async function recalculateProductRating(
|
||||
ctx: MutationCtx,
|
||||
productId: Id<"products">,
|
||||
) {
|
||||
const approved = await ctx.db
|
||||
.query("reviews")
|
||||
.withIndex("by_product_approved", (q) =>
|
||||
q.eq("productId", productId).eq("isApproved", true),
|
||||
)
|
||||
.collect();
|
||||
|
||||
const count = approved.length;
|
||||
const averageRating =
|
||||
count > 0
|
||||
? approved.reduce((sum, r) => sum + r.rating, 0) / count
|
||||
: undefined;
|
||||
const reviewCount = count > 0 ? count : undefined;
|
||||
|
||||
await ctx.db.patch(productId, {
|
||||
averageRating,
|
||||
reviewCount,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProductWithRelations(
|
||||
ctx: QueryCtx,
|
||||
productId: Id<"products">,
|
||||
) {
|
||||
const product = await ctx.db.get(productId);
|
||||
if (!product) return null;
|
||||
|
||||
const [imagesRaw, variants, category] = await Promise.all([
|
||||
ctx.db
|
||||
.query("productImages")
|
||||
.withIndex("by_product", (q) => q.eq("productId", productId))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", productId).eq("isActive", true),
|
||||
)
|
||||
.collect(),
|
||||
ctx.db.get(product.categoryId),
|
||||
]);
|
||||
const images = imagesRaw.sort((a, b) => a.position - b.position);
|
||||
|
||||
return { ...product, images, variants, category };
|
||||
}
|
||||
|
||||
export async function enrichProducts(
|
||||
ctx: QueryCtx,
|
||||
products: Awaited<ReturnType<typeof ctx.db.query>>[],
|
||||
) {
|
||||
return Promise.all(
|
||||
products.map(async (product: any) => {
|
||||
const [imagesRaw, variants] = await Promise.all([
|
||||
ctx.db
|
||||
.query("productImages")
|
||||
.withIndex("by_product", (q) => q.eq("productId", product._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query("productVariants")
|
||||
.withIndex("by_product_and_active", (q) =>
|
||||
q.eq("productId", product._id).eq("isActive", true),
|
||||
)
|
||||
.collect(),
|
||||
]);
|
||||
const images = imagesRaw.sort((a, b) => a.position - b.position);
|
||||
return { ...product, images, variants };
|
||||
}),
|
||||
);
|
||||
}
|
||||
775
convex/model/shippo.test.ts
Normal file
775
convex/model/shippo.test.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
validateAddressWithShippo,
|
||||
PREFERRED_CARRIERS,
|
||||
computeParcel,
|
||||
getShippingRatesFromShippo,
|
||||
selectBestRate,
|
||||
} from "./shippo";
|
||||
import type { ValidatedCartItem } from "./checkout";
|
||||
import type { ShippoRate } from "./checkout";
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
|
||||
const validInput = {
|
||||
addressLine1: "10 Downing Street",
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
};
|
||||
|
||||
const shippoValidResponse = {
|
||||
original_address: {
|
||||
address_line_1: "10 Downing Street",
|
||||
address_line_2: undefined,
|
||||
city_locality: "London",
|
||||
state_province: "Westminster",
|
||||
postal_code: "SW1A 2AA",
|
||||
country_code: "GB",
|
||||
},
|
||||
analysis: {
|
||||
validation_result: {
|
||||
value: "valid",
|
||||
reasons: [],
|
||||
},
|
||||
address_type: "unknown",
|
||||
changed_attributes: [],
|
||||
},
|
||||
};
|
||||
|
||||
const shippoPartiallyValidResponse = {
|
||||
original_address: {
|
||||
address_line_1: "10 Downing St",
|
||||
city_locality: "London",
|
||||
state_province: "Westminster",
|
||||
postal_code: "SW1A 2AA",
|
||||
country_code: "GB",
|
||||
},
|
||||
recommended_address: {
|
||||
address_line_1: "10 Downing Street",
|
||||
address_line_2: "Flat 1",
|
||||
city_locality: "London",
|
||||
state_province: "Westminster",
|
||||
postal_code: "SW1A 2AA",
|
||||
country_code: "GB",
|
||||
complete_address: "10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM",
|
||||
confidence_result: {
|
||||
score: "high",
|
||||
code: "postal_data_match",
|
||||
description: "Matched via postal data",
|
||||
},
|
||||
},
|
||||
analysis: {
|
||||
validation_result: {
|
||||
value: "partially_valid",
|
||||
reasons: [{ code: "street_suffix", description: "Street suffix corrected" }],
|
||||
},
|
||||
address_type: "unknown",
|
||||
changed_attributes: ["address_line_1", "address_line_2"],
|
||||
},
|
||||
};
|
||||
|
||||
const shippoInvalidResponse = {
|
||||
original_address: {
|
||||
address_line_1: "999 Nowhere Lane",
|
||||
city_locality: "Faketown",
|
||||
state_province: "",
|
||||
postal_code: "ZZ99 9ZZ",
|
||||
country_code: "GB",
|
||||
},
|
||||
analysis: {
|
||||
validation_result: {
|
||||
value: "invalid",
|
||||
reasons: [
|
||||
{ code: "address_not_found", description: "Address could not be found" },
|
||||
{ code: "invalid_postal_code", description: "Postal code is not valid" },
|
||||
],
|
||||
},
|
||||
address_type: "unknown",
|
||||
},
|
||||
};
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
vi.stubEnv("SHIPPO_API_KEY", "shippo_test_key_123");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function mockFetchOk(body: unknown) {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("validateAddressWithShippo", () => {
|
||||
// ── Request construction ────────────────────────────────────────────
|
||||
|
||||
it("builds correct URL with mapped query params (no state_province)", async () => {
|
||||
mockFetchOk(shippoValidResponse);
|
||||
|
||||
await validateAddressWithShippo({
|
||||
addressLine1: "10 Downing Street",
|
||||
additionalInformation: "Flat 1",
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
name: "Alice Smith",
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(fetchSpy.mock.calls[0][0]);
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://api.goshippo.com/v2/addresses/validate",
|
||||
);
|
||||
expect(url.searchParams.get("address_line_1")).toBe("10 Downing Street");
|
||||
expect(url.searchParams.get("address_line_2")).toBe("Flat 1");
|
||||
expect(url.searchParams.get("city_locality")).toBe("London");
|
||||
expect(url.searchParams.get("postal_code")).toBe("SW1A 2AA");
|
||||
expect(url.searchParams.get("country_code")).toBe("GB");
|
||||
expect(url.searchParams.get("name")).toBe("Alice Smith");
|
||||
expect(url.searchParams.has("state_province")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits optional params when not provided", async () => {
|
||||
mockFetchOk(shippoValidResponse);
|
||||
await validateAddressWithShippo(validInput);
|
||||
|
||||
const url = new URL(fetchSpy.mock.calls[0][0]);
|
||||
expect(url.searchParams.has("address_line_2")).toBe(false);
|
||||
expect(url.searchParams.has("name")).toBe(false);
|
||||
expect(url.searchParams.has("state_province")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends ShippoToken authorization header", async () => {
|
||||
mockFetchOk(shippoValidResponse);
|
||||
await validateAddressWithShippo(validInput);
|
||||
|
||||
const opts = fetchSpy.mock.calls[0][1];
|
||||
expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123");
|
||||
expect(opts.method).toBe("GET");
|
||||
});
|
||||
|
||||
// ── Valid address (no corrections) ──────────────────────────────────
|
||||
|
||||
it("returns isValid: true for a fully valid address", async () => {
|
||||
mockFetchOk(shippoValidResponse);
|
||||
const result = await validateAddressWithShippo(validInput);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.validationValue).toBe("valid");
|
||||
expect(result.reasons).toEqual([]);
|
||||
expect(result.addressType).toBe("unknown");
|
||||
expect(result.changedAttributes).toEqual([]);
|
||||
expect(result.recommendedAddress).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps originalAddress without state field", async () => {
|
||||
mockFetchOk(shippoValidResponse);
|
||||
const result = await validateAddressWithShippo(validInput);
|
||||
|
||||
expect(result.originalAddress).toEqual({
|
||||
addressLine1: "10 Downing Street",
|
||||
additionalInformation: undefined,
|
||||
city: "London",
|
||||
postalCode: "SW1A 2AA",
|
||||
country: "GB",
|
||||
});
|
||||
expect(result.originalAddress).not.toHaveProperty("state");
|
||||
});
|
||||
|
||||
// ── Partially valid address (with recommended) ─────────────────────
|
||||
|
||||
it("returns partially_valid result with recommendedAddress mapped", async () => {
|
||||
mockFetchOk(shippoPartiallyValidResponse);
|
||||
const result = await validateAddressWithShippo(validInput);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.validationValue).toBe("partially_valid");
|
||||
expect(result.addressType).toBe("unknown");
|
||||
expect(result.changedAttributes).toEqual(["address_line_1", "address_line_2"]);
|
||||
expect(result.reasons).toEqual([
|
||||
{ code: "street_suffix", description: "Street suffix corrected" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps recommended address with additionalInformation (no state)", async () => {
|
||||
mockFetchOk(shippoPartiallyValidResponse);
|
||||
const result = await validateAddressWithShippo(validInput);
|
||||
const rec = result.recommendedAddress;
|
||||
|
||||
expect(rec).toBeDefined();
|
||||
expect(rec!.addressLine1).toBe("10 Downing Street");
|
||||
expect(rec!.additionalInformation).toBe("Flat 1");
|
||||
expect(rec!.city).toBe("London");
|
||||
expect(rec!.postalCode).toBe("SW1A 2AA");
|
||||
expect(rec!.country).toBe("GB");
|
||||
expect(rec!.completeAddress).toBe(
|
||||
"10 Downing Street;Flat 1;LONDON;SW1A 2AA;UNITED KINGDOM",
|
||||
);
|
||||
expect(rec!.confidenceScore).toBe("high");
|
||||
expect(rec!.confidenceCode).toBe("postal_data_match");
|
||||
expect(rec!.confidenceDescription).toBe("Matched via postal data");
|
||||
expect(rec).not.toHaveProperty("state");
|
||||
});
|
||||
|
||||
// ── Invalid address ────────────────────────────────────────────────
|
||||
|
||||
it("returns isValid: false with reasons for an invalid address", async () => {
|
||||
mockFetchOk(shippoInvalidResponse);
|
||||
|
||||
const result = await validateAddressWithShippo({
|
||||
addressLine1: "999 Nowhere Lane",
|
||||
city: "Faketown",
|
||||
postalCode: "ZZ99 9ZZ",
|
||||
country: "GB",
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.validationValue).toBe("invalid");
|
||||
expect(result.addressType).toBe("unknown");
|
||||
expect(result.recommendedAddress).toBeUndefined();
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
expect(result.reasons[0].code).toBe("address_not_found");
|
||||
expect(result.reasons[1].code).toBe("invalid_postal_code");
|
||||
});
|
||||
|
||||
it("defaults changedAttributes to [] when missing from response", async () => {
|
||||
mockFetchOk(shippoInvalidResponse);
|
||||
const result = await validateAddressWithShippo({
|
||||
addressLine1: "999 Nowhere Lane",
|
||||
city: "Faketown",
|
||||
postalCode: "ZZ99 9ZZ",
|
||||
country: "GB",
|
||||
});
|
||||
expect(result.changedAttributes).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Error handling ─────────────────────────────────────────────────
|
||||
|
||||
it("throws when SHIPPO_API_KEY is missing", async () => {
|
||||
vi.stubEnv("SHIPPO_API_KEY", "");
|
||||
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
|
||||
/missing API key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when fetch rejects (network error)", async () => {
|
||||
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
|
||||
/unreachable/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when Shippo returns non-200 status", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
|
||||
/unavailable.*503/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when response body is not valid JSON", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected token")),
|
||||
});
|
||||
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
|
||||
/unexpected response/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when response is missing analysis.validation_result", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ original_address: {}, analysis: {} }),
|
||||
});
|
||||
await expect(validateAddressWithShippo(validInput)).rejects.toThrow(
|
||||
/malformed/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeCartItem(overrides: Partial<ValidatedCartItem> = {}): ValidatedCartItem {
|
||||
return {
|
||||
variantId: "variant1" as Id<"productVariants">,
|
||||
productId: "product1" as Id<"products">,
|
||||
quantity: 1,
|
||||
unitPrice: 1000,
|
||||
originalPrice: 1000,
|
||||
productName: "Test Product",
|
||||
variantName: "Default",
|
||||
sku: "TST-001",
|
||||
imageUrl: undefined,
|
||||
stockQuantity: 10,
|
||||
weight: 500,
|
||||
weightUnit: "g",
|
||||
length: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
dimensionUnit: undefined,
|
||||
productSlug: "test-product",
|
||||
parentCategorySlug: "parent",
|
||||
childCategorySlug: "child",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeShippoRate(overrides: Partial<ShippoRate> = {}): ShippoRate {
|
||||
return {
|
||||
objectId: "rate_abc123",
|
||||
provider: "DPD UK",
|
||||
servicelevelName: "Next Day",
|
||||
servicelevelToken: "dpd_uk_next_day",
|
||||
amount: "5.50",
|
||||
currency: "GBP",
|
||||
estimatedDays: 1,
|
||||
durationTerms: "1-2 business days",
|
||||
arrivesBy: null,
|
||||
carrierAccount: "ca_abc123",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── PREFERRED_CARRIERS ─────────────────────────────────────────────────────
|
||||
|
||||
describe("PREFERRED_CARRIERS", () => {
|
||||
it("contains the four expected carriers", () => {
|
||||
expect(PREFERRED_CARRIERS).toEqual(["DPD UK", "Evri UK", "UPS", "UDS"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── computeParcel ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("computeParcel", () => {
|
||||
// ── Weight normalization ────────────────────────────────────────────
|
||||
|
||||
it("sums weights in grams for items with weightUnit 'g'", () => {
|
||||
const items = [
|
||||
makeCartItem({ weight: 200, weightUnit: "g", quantity: 2 }),
|
||||
makeCartItem({ weight: 300, weightUnit: "g", quantity: 1 }),
|
||||
];
|
||||
const result = computeParcel(items);
|
||||
expect(result.weight).toBe("700");
|
||||
expect(result.mass_unit).toBe("g");
|
||||
});
|
||||
|
||||
it("converts kg to grams", () => {
|
||||
const items = [makeCartItem({ weight: 1.5, weightUnit: "kg", quantity: 1 })];
|
||||
const result = computeParcel(items);
|
||||
expect(result.weight).toBe("1500");
|
||||
});
|
||||
|
||||
it("converts lb to grams", () => {
|
||||
const items = [makeCartItem({ weight: 1, weightUnit: "lb", quantity: 1 })];
|
||||
const result = computeParcel(items);
|
||||
expect(result.weight).toBe("454");
|
||||
});
|
||||
|
||||
it("converts oz to grams", () => {
|
||||
const items = [makeCartItem({ weight: 1, weightUnit: "oz", quantity: 1 })];
|
||||
const result = computeParcel(items);
|
||||
expect(result.weight).toBe("28");
|
||||
});
|
||||
|
||||
it("multiplies weight by quantity", () => {
|
||||
const items = [makeCartItem({ weight: 100, weightUnit: "g", quantity: 5 })];
|
||||
const result = computeParcel(items);
|
||||
expect(result.weight).toBe("500");
|
||||
});
|
||||
|
||||
it("handles mixed weight units across items", () => {
|
||||
const items = [
|
||||
makeCartItem({ weight: 500, weightUnit: "g", quantity: 1 }),
|
||||
makeCartItem({ weight: 1, weightUnit: "kg", quantity: 2 }),
|
||||
];
|
||||
const result = computeParcel(items);
|
||||
// 500g + (1kg * 2) = 500 + 2000 = 2500g
|
||||
expect(result.weight).toBe("2500");
|
||||
});
|
||||
|
||||
// ── No dimensions ──────────────────────────────────────────────────
|
||||
|
||||
it("omits dimension fields when no items have dimensions", () => {
|
||||
const items = [makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined })];
|
||||
const result = computeParcel(items);
|
||||
expect(result).not.toHaveProperty("length");
|
||||
expect(result).not.toHaveProperty("width");
|
||||
expect(result).not.toHaveProperty("height");
|
||||
expect(result).not.toHaveProperty("distance_unit");
|
||||
});
|
||||
|
||||
it("omits dimensions when only some dimension fields present", () => {
|
||||
const items = [makeCartItem({ length: 10, width: undefined, height: 5, dimensionUnit: "cm" })];
|
||||
const result = computeParcel(items);
|
||||
expect(result).not.toHaveProperty("length");
|
||||
});
|
||||
|
||||
// ── With dimensions ────────────────────────────────────────────────
|
||||
|
||||
it("computes dimensions in cm: max length, max width, sum height", () => {
|
||||
const items = [
|
||||
makeCartItem({ length: 30, width: 20, height: 5, dimensionUnit: "cm", quantity: 2 }),
|
||||
makeCartItem({ length: 25, width: 25, height: 3, dimensionUnit: "cm", quantity: 1 }),
|
||||
];
|
||||
const result = computeParcel(items);
|
||||
expect(result.length).toBe("30");
|
||||
expect(result.width).toBe("25");
|
||||
// height: (5*2) + (3*1) = 13
|
||||
expect(result.height).toBe("13");
|
||||
expect(result.distance_unit).toBe("cm");
|
||||
});
|
||||
|
||||
it("converts inches to cm for dimensions", () => {
|
||||
const items = [
|
||||
makeCartItem({ length: 10, width: 8, height: 4, dimensionUnit: "in", quantity: 1 }),
|
||||
];
|
||||
const result = computeParcel(items);
|
||||
expect(result.length).toBe("25.4");
|
||||
expect(result.width).toBe("20.32");
|
||||
expect(result.height).toBe("10.16");
|
||||
expect(result.distance_unit).toBe("cm");
|
||||
});
|
||||
|
||||
it("ignores items without full dimensions when computing parcel dimensions", () => {
|
||||
const withDims = makeCartItem({ length: 20, width: 15, height: 10, dimensionUnit: "cm", quantity: 1, weight: 200, weightUnit: "g" });
|
||||
const withoutDims = makeCartItem({ length: undefined, width: undefined, height: undefined, dimensionUnit: undefined, quantity: 1, weight: 300, weightUnit: "g" });
|
||||
const result = computeParcel([withDims, withoutDims]);
|
||||
expect(result.length).toBe("20");
|
||||
expect(result.width).toBe("15");
|
||||
expect(result.height).toBe("10");
|
||||
// weight still sums both items
|
||||
expect(result.weight).toBe("500");
|
||||
});
|
||||
|
||||
it("handles mixed dimension units across items", () => {
|
||||
const items = [
|
||||
makeCartItem({ length: 10, width: 10, height: 5, dimensionUnit: "in", quantity: 1 }),
|
||||
makeCartItem({ length: 30, width: 20, height: 10, dimensionUnit: "cm", quantity: 1 }),
|
||||
];
|
||||
const result = computeParcel(items);
|
||||
// max length: max(25.4, 30) = 30
|
||||
expect(result.length).toBe("30");
|
||||
// max width: max(25.4, 20) = 25.4
|
||||
expect(result.width).toBe("25.4");
|
||||
// total height: 12.7 + 10 = 22.7
|
||||
expect(result.height).toBe("22.7");
|
||||
});
|
||||
|
||||
it("handles a single item with quantity > 1 stacking height", () => {
|
||||
const items = [makeCartItem({ length: 20, width: 15, height: 3, dimensionUnit: "cm", quantity: 4 })];
|
||||
const result = computeParcel(items);
|
||||
expect(result.height).toBe("12");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getShippingRatesFromShippo ─────────────────────────────────────────────
|
||||
|
||||
describe("getShippingRatesFromShippo", () => {
|
||||
const validShipmentsInput = {
|
||||
sourceAddressId: "addr_source_123",
|
||||
destinationAddress: {
|
||||
name: "John Doe",
|
||||
street1: "10 Downing Street",
|
||||
city: "London",
|
||||
zip: "SW1A 2AA",
|
||||
country: "GB",
|
||||
},
|
||||
parcels: [{ weight: "500", mass_unit: "g" }],
|
||||
};
|
||||
|
||||
const shippoShipmentsResponse = {
|
||||
object_id: "shp_abc123",
|
||||
rates: [
|
||||
{
|
||||
object_id: "rate_001",
|
||||
provider: "DPD UK",
|
||||
servicelevel: { name: "Next Day", token: "dpd_uk_next_day" },
|
||||
amount: "5.50",
|
||||
currency: "GBP",
|
||||
estimated_days: 1,
|
||||
duration_terms: "1-2 business days",
|
||||
arrives_by: null,
|
||||
carrier_account: "ca_dpd_001",
|
||||
},
|
||||
{
|
||||
object_id: "rate_002",
|
||||
provider: "UPS",
|
||||
servicelevel: { name: "Standard", token: "ups_standard" },
|
||||
amount: "7.99",
|
||||
currency: "GBP",
|
||||
estimated_days: 3,
|
||||
duration_terms: "3-5 business days",
|
||||
carrier_account: "ca_ups_001",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("sends correct POST request to Shippo /shipments/ endpoint", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(shippoShipmentsResponse),
|
||||
});
|
||||
|
||||
await getShippingRatesFromShippo(validShipmentsInput);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe("https://api.goshippo.com/shipments/");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers.Authorization).toBe("ShippoToken shippo_test_key_123");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.address_from).toBe("addr_source_123");
|
||||
expect(body.address_to.name).toBe("John Doe");
|
||||
expect(body.address_to.street1).toBe("10 Downing Street");
|
||||
expect(body.parcels).toHaveLength(1);
|
||||
expect(body.async).toBe(false);
|
||||
});
|
||||
|
||||
it("returns shipmentObjectId and mapped rates", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(shippoShipmentsResponse),
|
||||
});
|
||||
|
||||
const result = await getShippingRatesFromShippo(validShipmentsInput);
|
||||
|
||||
expect(result.shipmentObjectId).toBe("shp_abc123");
|
||||
expect(result.rates).toHaveLength(2);
|
||||
|
||||
const rate1 = result.rates[0];
|
||||
expect(rate1.objectId).toBe("rate_001");
|
||||
expect(rate1.provider).toBe("DPD UK");
|
||||
expect(rate1.servicelevelName).toBe("Next Day");
|
||||
expect(rate1.servicelevelToken).toBe("dpd_uk_next_day");
|
||||
expect(rate1.amount).toBe("5.50");
|
||||
expect(rate1.currency).toBe("GBP");
|
||||
expect(rate1.estimatedDays).toBe(1);
|
||||
expect(rate1.durationTerms).toBe("1-2 business days");
|
||||
expect(rate1.arrivesBy).toBeNull();
|
||||
expect(rate1.carrierAccount).toBe("ca_dpd_001");
|
||||
});
|
||||
|
||||
it("maps arrives_by to null when absent from response", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(shippoShipmentsResponse),
|
||||
});
|
||||
|
||||
const result = await getShippingRatesFromShippo(validShipmentsInput);
|
||||
expect(result.rates[1].arrivesBy).toBeNull();
|
||||
});
|
||||
|
||||
it("maps arrives_by when present in response", async () => {
|
||||
const responseWithArrival = {
|
||||
object_id: "shp_abc123",
|
||||
rates: [
|
||||
{
|
||||
...shippoShipmentsResponse.rates[0],
|
||||
arrives_by: "2025-03-05T18:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(responseWithArrival),
|
||||
});
|
||||
|
||||
const result = await getShippingRatesFromShippo(validShipmentsInput);
|
||||
expect(result.rates[0].arrivesBy).toBe("2025-03-05T18:00:00Z");
|
||||
});
|
||||
|
||||
it("throws when SHIPPO_API_KEY is missing", async () => {
|
||||
vi.stubEnv("SHIPPO_API_KEY", "");
|
||||
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
|
||||
/missing API key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when fetch rejects (network error)", async () => {
|
||||
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
|
||||
/unreachable/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when Shippo returns non-200 status", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
|
||||
/unavailable.*422/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when response body is not valid JSON", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.reject(new SyntaxError("Unexpected token")),
|
||||
});
|
||||
await expect(getShippingRatesFromShippo(validShipmentsInput)).rejects.toThrow(
|
||||
/unexpected response/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty rates array when Shippo returns no rates", async () => {
|
||||
fetchSpy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ object_id: "shp_empty", rates: [] }),
|
||||
});
|
||||
|
||||
const result = await getShippingRatesFromShippo(validShipmentsInput);
|
||||
expect(result.shipmentObjectId).toBe("shp_empty");
|
||||
expect(result.rates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── selectBestRate ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("selectBestRate", () => {
|
||||
it("throws when rates array is empty", () => {
|
||||
expect(() => selectBestRate([])).toThrow(
|
||||
/no shipping rates available/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("selects preferred carrier rate with fewest transit days", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "4.00" }),
|
||||
makeShippoRate({ provider: "Evri UK", estimatedDays: 1, amount: "6.00" }),
|
||||
makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "3.00" }),
|
||||
];
|
||||
|
||||
const { selected } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("Evri UK");
|
||||
expect(selected.estimatedDays).toBe(1);
|
||||
});
|
||||
|
||||
it("breaks ties by cheapest amount among same transit days", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "8.00" }),
|
||||
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }),
|
||||
makeShippoRate({ provider: "Evri UK", estimatedDays: 2, amount: "6.00" }),
|
||||
];
|
||||
|
||||
const { selected } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("DPD UK");
|
||||
expect(selected.amount).toBe("5.50");
|
||||
});
|
||||
|
||||
it("returns up to 2 alternatives from preferred carriers", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }),
|
||||
makeShippoRate({ provider: "UPS", estimatedDays: 2, amount: "7.00" }),
|
||||
makeShippoRate({ provider: "Evri UK", estimatedDays: 3, amount: "4.00" }),
|
||||
makeShippoRate({ provider: "UDS", estimatedDays: 4, amount: "3.50" }),
|
||||
];
|
||||
|
||||
const { selected, alternatives } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("DPD UK");
|
||||
expect(alternatives).toHaveLength(2);
|
||||
expect(alternatives[0].provider).toBe("UPS");
|
||||
expect(alternatives[1].provider).toBe("Evri UK");
|
||||
});
|
||||
|
||||
it("uses case-insensitive matching for carrier names", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "dpd uk", estimatedDays: 1, amount: "5.50" }),
|
||||
makeShippoRate({ provider: "EVRI UK", estimatedDays: 2, amount: "4.00" }),
|
||||
];
|
||||
|
||||
const { selected } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("dpd uk");
|
||||
});
|
||||
|
||||
it("falls back to all carriers when no preferred carriers present", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "Royal Mail", estimatedDays: 3, amount: "3.00" }),
|
||||
makeShippoRate({ provider: "Parcelforce", estimatedDays: 1, amount: "9.00" }),
|
||||
];
|
||||
|
||||
const { selected } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("Parcelforce");
|
||||
expect(selected.estimatedDays).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"No preferred carriers returned rates. Falling back to all carriers.",
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("fallback sorts all carriers by days then price", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "7.00" }),
|
||||
makeShippoRate({ provider: "Parcelforce", estimatedDays: 2, amount: "5.00" }),
|
||||
makeShippoRate({ provider: "Hermes", estimatedDays: 3, amount: "3.00" }),
|
||||
];
|
||||
|
||||
const { selected, alternatives } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("Parcelforce");
|
||||
expect(selected.amount).toBe("5.00");
|
||||
expect(alternatives).toHaveLength(2);
|
||||
expect(alternatives[0].provider).toBe("Royal Mail");
|
||||
expect(alternatives[1].provider).toBe("Hermes");
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns single preferred rate with empty alternatives", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "DPD UK", estimatedDays: 1, amount: "5.50" }),
|
||||
makeShippoRate({ provider: "Royal Mail", estimatedDays: 2, amount: "3.00" }),
|
||||
];
|
||||
|
||||
const { selected, alternatives } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("DPD UK");
|
||||
expect(alternatives).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters out non-preferred carriers from selection when preferred exist", () => {
|
||||
const rates = [
|
||||
makeShippoRate({ provider: "Royal Mail", estimatedDays: 1, amount: "2.00" }),
|
||||
makeShippoRate({ provider: "DPD UK", estimatedDays: 2, amount: "5.50" }),
|
||||
];
|
||||
|
||||
const { selected } = selectBestRate(rates);
|
||||
// DPD UK is preferred, even though Royal Mail is faster and cheaper
|
||||
expect(selected.provider).toBe("DPD UK");
|
||||
});
|
||||
|
||||
it("handles single rate in the array", () => {
|
||||
const rates = [makeShippoRate({ provider: "UPS", estimatedDays: 3, amount: "10.00" })];
|
||||
|
||||
const { selected, alternatives } = selectBestRate(rates);
|
||||
expect(selected.provider).toBe("UPS");
|
||||
expect(alternatives).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
380
convex/model/shippo.ts
Normal file
380
convex/model/shippo.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type {
|
||||
AddressValidationResult,
|
||||
RecommendedAddress,
|
||||
ShippoRate,
|
||||
ValidatedCartItem,
|
||||
} from "./checkout";
|
||||
|
||||
type ValidateAddressInput = {
|
||||
addressLine1: string;
|
||||
additionalInformation?: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ShippoRawResponse = {
|
||||
original_address: {
|
||||
address_line_1: string;
|
||||
address_line_2?: string;
|
||||
city_locality: string;
|
||||
state_province: string;
|
||||
postal_code: string;
|
||||
country_code: string;
|
||||
name?: string;
|
||||
organization?: string;
|
||||
};
|
||||
recommended_address?: {
|
||||
address_line_1: string;
|
||||
address_line_2?: string;
|
||||
city_locality: string;
|
||||
state_province: string;
|
||||
postal_code: string;
|
||||
country_code: string;
|
||||
complete_address?: string;
|
||||
confidence_result: {
|
||||
score: "high" | "medium" | "low";
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
analysis: {
|
||||
validation_result: {
|
||||
value: "valid" | "partially_valid" | "invalid";
|
||||
reasons: Array<{ code: string; description: string }>;
|
||||
};
|
||||
address_type:
|
||||
| "residential"
|
||||
| "commercial"
|
||||
| "unknown"
|
||||
| "po_box"
|
||||
| "military";
|
||||
changed_attributes?: string[];
|
||||
};
|
||||
geo?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SHIPPO_VALIDATE_URL =
|
||||
"https://api.goshippo.com/v2/addresses/validate";
|
||||
|
||||
/**
|
||||
* Calls Shippo Address Validation v2 and normalizes the response into
|
||||
* an `AddressValidationResult`. This is a pure async helper — it does NOT
|
||||
* export a Convex function; it's consumed by actions in `checkoutActions.ts`.
|
||||
*/
|
||||
export async function validateAddressWithShippo(
|
||||
input: ValidateAddressInput,
|
||||
): Promise<AddressValidationResult> {
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ConvexError(
|
||||
"Address validation is unavailable (missing API key configuration).",
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("address_line_1", input.addressLine1);
|
||||
if (input.additionalInformation)
|
||||
params.set("address_line_2", input.additionalInformation);
|
||||
params.set("city_locality", input.city);
|
||||
params.set("postal_code", input.postalCode);
|
||||
params.set("country_code", input.country);
|
||||
if (input.name) params.set("name", input.name);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${SHIPPO_VALIDATE_URL}?${params.toString()}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `ShippoToken ${apiKey}` },
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ConvexError(
|
||||
"Address validation service is unreachable. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ConvexError(
|
||||
`Address validation service unavailable (status ${response.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
let body: ShippoRawResponse;
|
||||
try {
|
||||
body = (await response.json()) as ShippoRawResponse;
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Address validation returned an unexpected response. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.analysis?.validation_result) {
|
||||
throw new ConvexError(
|
||||
"Address validation returned a malformed response.",
|
||||
);
|
||||
}
|
||||
|
||||
const { analysis, recommended_address, original_address } = body;
|
||||
|
||||
let recommendedAddress: RecommendedAddress | undefined;
|
||||
if (recommended_address) {
|
||||
recommendedAddress = {
|
||||
addressLine1: recommended_address.address_line_1,
|
||||
additionalInformation: recommended_address.address_line_2,
|
||||
city: recommended_address.city_locality,
|
||||
postalCode: recommended_address.postal_code,
|
||||
country: recommended_address.country_code,
|
||||
completeAddress: recommended_address.complete_address,
|
||||
confidenceScore: recommended_address.confidence_result.score,
|
||||
confidenceCode: recommended_address.confidence_result.code,
|
||||
confidenceDescription:
|
||||
recommended_address.confidence_result.description,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: analysis.validation_result.value === "valid",
|
||||
validationValue: analysis.validation_result.value,
|
||||
reasons: analysis.validation_result.reasons.map((r) => ({
|
||||
code: r.code,
|
||||
description: r.description,
|
||||
})),
|
||||
addressType: analysis.address_type,
|
||||
changedAttributes: analysis.changed_attributes ?? [],
|
||||
recommendedAddress,
|
||||
originalAddress: {
|
||||
addressLine1: original_address.address_line_1,
|
||||
additionalInformation: original_address.address_line_2,
|
||||
city: original_address.city_locality,
|
||||
postalCode: original_address.postal_code,
|
||||
country: original_address.country_code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Shipping Rates Helpers ──────────────────────────────────────────────────
|
||||
|
||||
export const PREFERRED_CARRIERS = ["DPD UK", "Evri UK", "UPS", "UDS"];
|
||||
|
||||
/**
|
||||
* Hard ceiling across preferred UK carriers.
|
||||
* DPD UK premium (door-to-door, Saturday/Sunday): 30kg
|
||||
* DPD UK standard (Classic, Next Day, Two Day): 20kg
|
||||
* Evri UK (Courier Collection, ParcelShop): 15kg
|
||||
*/
|
||||
export const MAX_PARCEL_WEIGHT_G = 30_000;
|
||||
|
||||
const WEIGHT_TO_GRAMS: Record<ValidatedCartItem["weightUnit"], number> = {
|
||||
g: 1,
|
||||
kg: 1000,
|
||||
lb: 453.592,
|
||||
oz: 28.3495,
|
||||
};
|
||||
|
||||
const DIMENSION_TO_CM: Record<NonNullable<ValidatedCartItem["dimensionUnit"]>, number> = {
|
||||
cm: 1,
|
||||
in: 2.54,
|
||||
};
|
||||
|
||||
type ParcelResult = {
|
||||
weight: string;
|
||||
mass_unit: "g";
|
||||
length?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
distance_unit?: "cm";
|
||||
};
|
||||
|
||||
export function computeParcel(items: ValidatedCartItem[]): ParcelResult {
|
||||
let totalWeightGrams = 0;
|
||||
for (const item of items) {
|
||||
const factor = WEIGHT_TO_GRAMS[item.weightUnit];
|
||||
totalWeightGrams += item.weight * factor * item.quantity;
|
||||
}
|
||||
|
||||
const withDimensions = items.filter(
|
||||
(item): item is ValidatedCartItem & { length: number; width: number; height: number; dimensionUnit: "cm" | "in" } =>
|
||||
item.length != null && item.width != null && item.height != null && item.dimensionUnit != null,
|
||||
);
|
||||
|
||||
if (withDimensions.length === 0) {
|
||||
return { weight: String(Math.round(totalWeightGrams)), mass_unit: "g" };
|
||||
}
|
||||
|
||||
let maxLengthCm = 0;
|
||||
let maxWidthCm = 0;
|
||||
let totalHeightCm = 0;
|
||||
|
||||
for (const item of withDimensions) {
|
||||
const factor = DIMENSION_TO_CM[item.dimensionUnit];
|
||||
const lengthCm = item.length * factor;
|
||||
const widthCm = item.width * factor;
|
||||
const heightCm = item.height * factor * item.quantity;
|
||||
|
||||
if (lengthCm > maxLengthCm) maxLengthCm = lengthCm;
|
||||
if (widthCm > maxWidthCm) maxWidthCm = widthCm;
|
||||
totalHeightCm += heightCm;
|
||||
}
|
||||
|
||||
return {
|
||||
weight: String(Math.round(totalWeightGrams)),
|
||||
mass_unit: "g",
|
||||
length: String(Math.round(maxLengthCm * 100) / 100),
|
||||
width: String(Math.round(maxWidthCm * 100) / 100),
|
||||
height: String(Math.round(totalHeightCm * 100) / 100),
|
||||
distance_unit: "cm",
|
||||
};
|
||||
}
|
||||
|
||||
const SHIPPO_SHIPMENTS_URL = "https://api.goshippo.com/shipments/";
|
||||
|
||||
export async function getShippingRatesFromShippo(input: {
|
||||
sourceAddressId: string;
|
||||
destinationAddress: {
|
||||
name: string;
|
||||
street1: string;
|
||||
street2?: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
phone?: string;
|
||||
};
|
||||
parcels: Array<{
|
||||
weight: string;
|
||||
mass_unit: string;
|
||||
length?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
distance_unit?: string;
|
||||
}>;
|
||||
}): Promise<{ shipmentObjectId: string; rates: ShippoRate[] }> {
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service is unavailable (missing API key configuration).",
|
||||
);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
address_from: input.sourceAddressId,
|
||||
address_to: input.destinationAddress,
|
||||
parcels: input.parcels,
|
||||
async: false,
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(SHIPPO_SHIPMENTS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `ShippoToken ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service is unreachable. Please try again later.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetail = "";
|
||||
try {
|
||||
const errBody = await response.json();
|
||||
errorDetail = JSON.stringify(errBody);
|
||||
console.error("Shippo /shipments/ error:", response.status, errorDetail);
|
||||
} catch {
|
||||
console.error("Shippo /shipments/ error:", response.status, "(no parseable body)");
|
||||
}
|
||||
throw new ConvexError(
|
||||
`Shipping rate service unavailable (status ${response.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
let body: {
|
||||
object_id: string;
|
||||
messages?: Array<{ source: string; text: string }>;
|
||||
rates: Array<{
|
||||
object_id: string;
|
||||
provider: string;
|
||||
servicelevel: { name: string; token: string };
|
||||
amount: string;
|
||||
currency: string;
|
||||
estimated_days: number;
|
||||
duration_terms: string;
|
||||
arrives_by?: string | null;
|
||||
carrier_account: string;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ConvexError(
|
||||
"Shipping rate service returned an unexpected response. Please try again.",
|
||||
);
|
||||
}
|
||||
|
||||
if (body.rates.length === 0 && body.messages?.length) {
|
||||
console.warn(
|
||||
"Shippo returned 0 rates. Carrier messages:",
|
||||
body.messages.map((m) => `[${m.source}] ${m.text}`).join(" | "),
|
||||
);
|
||||
}
|
||||
|
||||
const rates: ShippoRate[] = body.rates.map((rate) => ({
|
||||
objectId: rate.object_id,
|
||||
provider: rate.provider,
|
||||
servicelevelName: rate.servicelevel.name,
|
||||
servicelevelToken: rate.servicelevel.token,
|
||||
amount: rate.amount,
|
||||
currency: rate.currency,
|
||||
estimatedDays: rate.estimated_days,
|
||||
durationTerms: rate.duration_terms,
|
||||
arrivesBy: rate.arrives_by ?? null,
|
||||
carrierAccount: rate.carrier_account,
|
||||
}));
|
||||
|
||||
return { shipmentObjectId: body.object_id, rates };
|
||||
}
|
||||
|
||||
export function selectBestRate(rates: ShippoRate[]): {
|
||||
selected: ShippoRate;
|
||||
alternatives: ShippoRate[];
|
||||
} {
|
||||
if (rates.length === 0) {
|
||||
throw new ConvexError(
|
||||
"No shipping rates available for this address. Please verify your address and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const preferredLower = PREFERRED_CARRIERS.map((c) => c.toLowerCase());
|
||||
|
||||
const preferred = rates.filter((r) =>
|
||||
preferredLower.includes(r.provider.toLowerCase()),
|
||||
);
|
||||
|
||||
const sortByDaysThenPrice = (a: ShippoRate, b: ShippoRate) => {
|
||||
const aDays = a.estimatedDays ?? Infinity;
|
||||
const bDays = b.estimatedDays ?? Infinity;
|
||||
const daysDiff = aDays - bDays;
|
||||
if (daysDiff !== 0) return daysDiff;
|
||||
return parseFloat(a.amount) - parseFloat(b.amount);
|
||||
};
|
||||
|
||||
if (preferred.length > 0) {
|
||||
preferred.sort(sortByDaysThenPrice);
|
||||
return { selected: preferred[0], alternatives: preferred.slice(1, 3) };
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"No preferred carriers returned rates. Falling back to all carriers.",
|
||||
);
|
||||
const sorted = [...rates].sort(sortByDaysThenPrice);
|
||||
return { selected: sorted[0], alternatives: sorted.slice(1, 3) };
|
||||
}
|
||||
24
convex/model/stripe.ts
Normal file
24
convex/model/stripe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
"use node";
|
||||
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
export async function getOrCreateStripeCustomer(input: {
|
||||
stripeCustomerId: string | undefined;
|
||||
email: string;
|
||||
name: string;
|
||||
convexUserId: string;
|
||||
}): Promise<string> {
|
||||
if (input.stripeCustomerId) {
|
||||
return input.stripeCustomerId;
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
metadata: { convexUserId: input.convexUserId },
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
38
convex/model/users.ts
Normal file
38
convex/model/users.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
|
||||
type AuthCtx = QueryCtx | MutationCtx;
|
||||
|
||||
export async function getCurrentUser(ctx: QueryCtx) {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_external_id", (q) => q.eq("externalId", identity.subject))
|
||||
.unique();
|
||||
}
|
||||
|
||||
export async function getCurrentUserOrThrow(ctx: AuthCtx) {
|
||||
const user = await getCurrentUser(ctx as QueryCtx);
|
||||
if (!user) throw new Error("Unauthenticated");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdmin(ctx: QueryCtx) {
|
||||
const user = await getCurrentUserOrThrow(ctx);
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
throw new Error("Unauthorized: admin access required");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireOwnership(
|
||||
ctx: AuthCtx,
|
||||
resourceUserId: Id<"users">,
|
||||
) {
|
||||
const user = await getCurrentUserOrThrow(ctx);
|
||||
if (resourceUserId !== user._id) {
|
||||
throw new Error("Unauthorized: resource does not belong to you");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
Reference in New Issue
Block a user