diff --git a/backend/prisma/migrations/20261004174637_add_order_payment_mismatch_fields/migration.sql b/backend/prisma/migrations/20261004174637_add_order_payment_mismatch_fields/migration.sql new file mode 100644 index 00000000..94c86e5c --- /dev/null +++ b/backend/prisma/migrations/20261004174637_add_order_payment_mismatch_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "paid_amount" MONEY, +ADD COLUMN "payment_mismatch_at" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 362d64a8..c75da536 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -135,12 +135,14 @@ model Item { } model Order { - id String @id @default(uuid()) @db.Uuid - paymentMethod PaymentMethod @map("payment_method") - paymentStatus PaymentStatus @map("payment_status") - pickedUp Boolean @default(false) @map("picked_up") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) @db.Uuid + paymentMethod PaymentMethod @map("payment_method") + paymentStatus PaymentStatus @map("payment_status") + pickedUp Boolean @default(false) @map("picked_up") + paidAmount Decimal? @db.Money @map("paid_amount") + paymentMismatchAt DateTime? @map("payment_mismatch_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") paymentRemindedAt DateTime? @map("payment_reminded_at") pickupRemindedAt DateTime? @map("pickup_reminded_at") diff --git a/backend/src/__tests__/fixtures/index.ts b/backend/src/__tests__/fixtures/index.ts index 79d1947f..de02e3b3 100644 --- a/backend/src/__tests__/fixtures/index.ts +++ b/backend/src/__tests__/fixtures/index.ts @@ -85,6 +85,8 @@ export const createMockOrder = (overrides?: Partial): Order => ({ buyerId: "123e4567-e89b-12d3-a456-426614174000", fundraiserId: "423e4567-e89b-12d3-a456-426614174000", referralId: null, + paidAmount: null, + paymentMismatchAt: null, createdAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-01T00:00:00.000Z"), ...overrides, diff --git a/backend/src/api/email/__tests__/email.services.test.ts b/backend/src/api/email/__tests__/email.services.test.ts new file mode 100644 index 00000000..e22baf85 --- /dev/null +++ b/backend/src/api/email/__tests__/email.services.test.ts @@ -0,0 +1,90 @@ +import { Decimal } from "decimal.js"; +import { createMockUser } from "../../../__tests__"; + +// email.services.ts pulls in cheerio (for the Venmo email parsers), which +// drags in undici and needs web globals (File, etc.) that aren't present +// under Jest's node environment. Stub it out — none of these tests touch +// the parsing functions. +jest.mock("cheerio", () => ({ load: jest.fn() })); +jest.mock("../../order/order.services"); +jest.mock("../../../utils/email"); + +import { updateOrderPaymentStatus } from "../email.services"; +import * as orderServices from "../../order/order.services"; +import * as emailUtils from "../../../utils/email"; + +describe("Email Services", () => { + const orderId = "323e4567-e89b-12d3-a456-426614174000"; + + beforeEach(() => { + jest.clearAllMocks(); + (orderServices.calculateOrderTotal as jest.Mock).mockResolvedValue( + new Decimal("10.00") + ); + }); + + describe("updateOrderPaymentStatus", () => { + it.each([ + ["exactly matches", "10.00"], + ["is within the 1-cent tolerance", "10.01"], + ])("should confirm the order when the amount %s", async (_case, paid) => { + const confirmedOrder = { id: orderId, paymentStatus: "CONFIRMED" }; + (orderServices.confirmOrderPayment as jest.Mock).mockResolvedValue( + confirmedOrder + ); + + const result = await updateOrderPaymentStatus(orderId, new Decimal(paid)); + + expect(orderServices.confirmOrderPayment).toHaveBeenCalledWith( + orderId, + "VENMO" + ); + expect(orderServices.recordOrderPaymentMismatch).not.toHaveBeenCalled(); + expect(emailUtils.sendPaymentMismatchEmail).not.toHaveBeenCalled(); + expect(result).toEqual(confirmedOrder); + }); + + it("should record a mismatch and email the buyer when the amount doesn't match", async () => { + const mockBuyer = createMockUser(); + const mismatchedOrder = { + id: orderId, + buyer: mockBuyer, + fundraiser: { name: "Test Fundraiser" }, + }; + (orderServices.recordOrderPaymentMismatch as jest.Mock).mockResolvedValue( + mismatchedOrder + ); + + const result = await updateOrderPaymentStatus( + orderId, + new Decimal("15.00") + ); + + expect(orderServices.recordOrderPaymentMismatch).toHaveBeenCalledWith( + orderId, + new Decimal("15.00") + ); + expect(emailUtils.sendPaymentMismatchEmail).toHaveBeenCalledWith({ + buyer: mockBuyer, + fundraiserName: "Test Fundraiser", + orderId, + expectedAmount: new Decimal("10.00"), + paidAmount: new Decimal("15.00"), + }); + expect(orderServices.confirmOrderPayment).not.toHaveBeenCalled(); + expect(result).toEqual(mismatchedOrder); + }); + + it("should wrap errors from confirmOrderPayment with a descriptive message", async () => { + (orderServices.confirmOrderPayment as jest.Mock).mockRejectedValue( + new Error("Insufficient stock for Test Item") + ); + + await expect( + updateOrderPaymentStatus(orderId, new Decimal("10.00")) + ).rejects.toThrow( + "Failed to update order payment status: Insufficient stock for Test Item" + ); + }); + }); +}); diff --git a/backend/src/api/email/email.services.ts b/backend/src/api/email/email.services.ts index b2e4f4b7..719cc8f3 100644 --- a/backend/src/api/email/email.services.ts +++ b/backend/src/api/email/email.services.ts @@ -1,7 +1,11 @@ import { load } from "cheerio"; import { Decimal } from "decimal.js"; -import { prisma } from "../../utils/prisma"; -import { calculateOrderTotal } from "../order/order.services"; +import { sendPaymentMismatchEmail } from "../../utils/email"; +import { + calculateOrderTotal, + confirmOrderPayment, + recordOrderPaymentMismatch, +} from "../order/order.services"; export const parseUnverifiedVenmoEmail = (raw: string) => { let parsedAmount: Decimal | null = null; @@ -57,36 +61,33 @@ export const updateOrderPaymentStatus = async ( orderId: string, paidAmount: Decimal ) => { - try { - // Calculate expected order total - const expectedAmount = await calculateOrderTotal(orderId); - - // Validate that paid amount matches expected amount using Decimal comparison - const tolerance = new Decimal(0.01); - const difference = paidAmount.minus(expectedAmount).abs(); - - if (difference.greaterThan(tolerance)) { - // TODO: Handle amount mismatch (e.g., log, alert, etc.) - - throw new Error( - `Payment amount mismatch: expected $${expectedAmount.toFixed( - 2 - )}, received $${paidAmount.toFixed(2)}` - ); - } - - // Update order with Venmo payment confirmation - const order = await prisma.order.update({ - where: { - id: orderId, - }, - data: { - paymentMethod: "VENMO", - paymentStatus: "CONFIRMED", - }, + // Calculate expected order total + const expectedAmount = await calculateOrderTotal(orderId); + + // Validate that paid amount matches expected amount using Decimal comparison + const tolerance = new Decimal(0.01); + const difference = paidAmount.minus(expectedAmount).abs(); + + if (difference.greaterThan(tolerance)) { + // Record the mismatch and let the buyer know, rather than silently + // leaving the order PENDING (which would keep sending them payment reminders). + const order = await recordOrderPaymentMismatch(orderId, paidAmount); + + await sendPaymentMismatchEmail({ + buyer: order.buyer, + fundraiserName: order.fundraiser.name, + orderId: order.id, + expectedAmount, + paidAmount, }); return order; + } + + try { + // Route through confirmOrderPayment so the inventory check and + // analytics cache refresh that manual confirmation gets also apply here. + return await confirmOrderPayment(orderId, "VENMO"); } catch (error) { throw new Error( `Failed to update order payment status: ${ diff --git a/backend/src/api/order/__tests__/order.services.test.ts b/backend/src/api/order/__tests__/order.services.test.ts new file mode 100644 index 00000000..64115529 --- /dev/null +++ b/backend/src/api/order/__tests__/order.services.test.ts @@ -0,0 +1,147 @@ +import { Decimal } from "decimal.js"; +import { prismaMock, createMockUser, createMockOrder } from "../../../__tests__"; +import { + confirmOrderPayment, + recordOrderPaymentMismatch, + getUnremindedUnpaidOrders, +} from "../order.services"; +import * as fundraiserServices from "../../fundraiser/fundraiser.services"; + +jest.mock("../../fundraiser/fundraiser.services"); + +const orderId = "323e4567-e89b-12d3-a456-426614174000"; +const fundraiserId = "423e4567-e89b-12d3-a456-426614174000"; + +describe("Order Services", () => { + beforeEach(() => { + jest.clearAllMocks(); + // $transaction just runs the callback against the same mocked client + prismaMock.$transaction.mockImplementation(((callback: any) => + callback(prismaMock)) as any); + }); + + describe("confirmOrderPayment", () => { + it("should throw when the order doesn't exist", async () => { + prismaMock.order.findUnique.mockResolvedValue(null); + + await expect(confirmOrderPayment(orderId)).rejects.toThrow( + "Order not found" + ); + }); + + it.each([ + [undefined, { paymentStatus: "CONFIRMED" as const }], + [ + "VENMO" as const, + { paymentStatus: "CONFIRMED" as const, paymentMethod: "VENMO" as const }, + ], + ])( + "should confirm the order, refreshing the cache (paymentMethod=%s)", + async (paymentMethod, expectedData) => { + prismaMock.order.findUnique.mockResolvedValue({ + ...createMockOrder({ id: orderId }), + items: [], + } as any); + const confirmedOrder = { + ...createMockOrder({ id: orderId, ...expectedData }), + buyer: createMockUser(), + fundraiser: { id: fundraiserId }, + }; + prismaMock.order.update.mockResolvedValue(confirmedOrder as any); + + const result = await confirmOrderPayment(orderId, paymentMethod); + + expect(prismaMock.order.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: orderId }, data: expectedData }) + ); + expect(result).toEqual(confirmedOrder); + expect( + fundraiserServices.updateCacheForOrderConfirmation + ).toHaveBeenCalledWith(fundraiserId, confirmedOrder); + } + ); + + it("should not update or re-count the cache when the order is already confirmed", async () => { + prismaMock.order.findUnique.mockResolvedValue({ + ...createMockOrder({ id: orderId, paymentStatus: "CONFIRMED" }), + items: [], + } as any); + const existingOrder = { + ...createMockOrder({ id: orderId, paymentStatus: "CONFIRMED" }), + buyer: createMockUser(), + fundraiser: { id: fundraiserId }, + }; + prismaMock.order.findUniqueOrThrow.mockResolvedValue(existingOrder as any); + + const result = await confirmOrderPayment(orderId, "VENMO"); + + expect(result).toEqual(existingOrder); + expect(prismaMock.order.update).not.toHaveBeenCalled(); + expect( + fundraiserServices.updateCacheForOrderConfirmation + ).not.toHaveBeenCalled(); + }); + + it("should reject when a limited item is already sold out", async () => { + prismaMock.order.findUnique.mockResolvedValue({ + ...createMockOrder({ id: orderId }), + items: [{ itemId: "item-1", quantity: 1 }], + } as any); + prismaMock.item.findMany.mockResolvedValue([ + { id: "item-1", limit: 1, name: "Limited Item" } as any, + ]); + (prismaMock.orderItems.groupBy as jest.Mock).mockResolvedValue([ + { itemId: "item-1", _sum: { quantity: 1 } }, + ]); + + await expect(confirmOrderPayment(orderId)).rejects.toThrow( + "Insufficient stock for Limited Item" + ); + expect(prismaMock.order.update).not.toHaveBeenCalled(); + }); + }); + + describe("recordOrderPaymentMismatch", () => { + it("should store the paid amount and a mismatch timestamp", async () => { + const mismatchedOrder = { + ...createMockOrder({ id: orderId }), + buyer: createMockUser(), + fundraiser: { id: fundraiserId, name: "Test Fundraiser" }, + }; + prismaMock.order.update.mockResolvedValue(mismatchedOrder as any); + + const result = await recordOrderPaymentMismatch( + orderId, + new Decimal("15.50") + ); + + expect(prismaMock.order.update).toHaveBeenCalledWith({ + where: { id: orderId }, + data: { paidAmount: "15.50", paymentMismatchAt: expect.any(Date) }, + include: { + buyer: true, + fundraiser: { select: { id: true, name: true } }, + }, + }); + expect(result).toEqual(mismatchedOrder); + }); + }); + + describe("getUnremindedUnpaidOrders", () => { + it("should exclude orders already flagged with a payment mismatch", async () => { + prismaMock.order.findMany.mockResolvedValue([]); + + await getUnremindedUnpaidOrders(); + + expect(prismaMock.order.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + paymentStatus: "PENDING", + paymentMethod: "VENMO", + paymentMismatchAt: null, + }), + }) + ); + }); + }); +}); diff --git a/backend/src/api/order/order.services.ts b/backend/src/api/order/order.services.ts index bdcdc862..dc0b0e97 100644 --- a/backend/src/api/order/order.services.ts +++ b/backend/src/api/order/order.services.ts @@ -1,4 +1,4 @@ -import { Prisma } from "../../generated/client"; +import { Prisma, PaymentMethod } from "../../generated/client"; import { prisma } from "../../utils/prisma"; import { CreateOrderBody } from "common"; import { z } from "zod"; @@ -469,7 +469,56 @@ export const undoOrderPickup = async (orderId: string) => { return order; }; -export const confirmOrderPayment = async (orderId: string) => { +const confirmedOrderInclude = { + buyer: true, + items: { + include: { + item: { + select: { + name: true, + price: true, + }, + }, + }, + }, + fundraiser: { + select: { + id: true, + name: true, + description: true, + published: true, + goalAmount: true, + imageUrls: true, + buyingStartsAt: true, + buyingEndsAt: true, + organization: { + select: { + id: true, + name: true, + description: true, + authorized: true, + logoUrl: true, + }, + }, + pickupEvents: { + orderBy: { + startsAt: "asc", + }, + }, + }, + }, + referral: { + include: { + referrer: true, + }, + }, +} satisfies Prisma.OrderInclude; + +export const confirmOrderPayment = async ( + orderId: string, + paymentMethod?: PaymentMethod, +) => { + let alreadyConfirmed = false; const order = await prisma.$transaction( async (tx) => { const existingOrder = await tx.order.findUnique({ @@ -488,6 +537,16 @@ export const confirmOrderPayment = async (orderId: string) => { throw new Error("Order not found"); } + // Already confirmed: return it unchanged so the analytics cache + // isn't updated a second time for the same order. + if (existingOrder.paymentStatus === "CONFIRMED") { + alreadyConfirmed = true; + return tx.order.findUniqueOrThrow({ + where: { id: orderId }, + include: confirmedOrderInclude, + }); + } + await assertInventoryAvailable( tx, existingOrder.items.map((item) => ({ @@ -501,58 +560,18 @@ export const confirmOrderPayment = async (orderId: string) => { where: { id: orderId }, data: { paymentStatus: "CONFIRMED", + ...(paymentMethod && { paymentMethod }), }, - include: { - buyer: true, - items: { - include: { - item: { - select: { - name: true, - price: true, - }, - }, - }, - }, - fundraiser: { - select: { - id: true, - name: true, - description: true, - published: true, - goalAmount: true, - imageUrls: true, - buyingStartsAt: true, - buyingEndsAt: true, - organization: { - select: { - id: true, - name: true, - description: true, - authorized: true, - logoUrl: true, - }, - }, - pickupEvents: { - orderBy: { - startsAt: "asc", - }, - }, - }, - }, - referral: { - include: { - referrer: true, - }, - }, - }, + include: confirmedOrderInclude, }); }, { isolationLevel: "Serializable" } ); // Update analytics cache for the fundraiser when payment is confirmed - await updateCacheForOrderConfirmation(order.fundraiser.id, order); + if (!alreadyConfirmed) { + await updateCacheForOrderConfirmation(order.fundraiser.id, order); + } return order; }; @@ -567,6 +586,7 @@ export const getUnremindedUnpaidOrders = async () => { where: { paymentStatus: "PENDING", paymentMethod: "VENMO", + paymentMismatchAt: null, createdAt: { lte: oneHourAgo, }, @@ -715,3 +735,30 @@ export const calculateOrderTotal = async ( return total; }; + +/** + * Flag an order as having received a Venmo payment that doesn't match its + * expected total. Leaves paymentStatus as-is (PENDING) so an admin can + * resolve it manually via confirmOrderPayment once the discrepancy is sorted out. + */ +export const recordOrderPaymentMismatch = async ( + orderId: string, + paidAmount: Decimal, +) => { + return prisma.order.update({ + where: { id: orderId }, + data: { + paidAmount: paidAmount.toFixed(2), + paymentMismatchAt: new Date(), + }, + include: { + buyer: true, + fundraiser: { + select: { + id: true, + name: true, + }, + }, + }, + }); +}; diff --git a/backend/src/utils/email.ts b/backend/src/utils/email.ts index 123bc7d3..3edfeee8 100644 --- a/backend/src/utils/email.ts +++ b/backend/src/utils/email.ts @@ -10,6 +10,7 @@ import { } from "common"; import { addDays, format, isSameDay } from "date-fns"; import { TZDate } from "@date-fns/tz"; +import { Decimal } from "decimal.js"; import { escapeHtml } from "./html"; type Order = z.infer; @@ -433,6 +434,58 @@ export const sendPaymentReminderEmail = async (order: Order): Promise => { }); }; +/** + * Notify a buyer that their Venmo payment didn't match the order total + */ +export const sendPaymentMismatchEmail = async (options: { + buyer: { name: string; email: string }; + fundraiserName: string; + orderId: string; + expectedAmount: Decimal; + paidAmount: Decimal; +}): Promise => { + const { buyer, fundraiserName, orderId, expectedAmount, paidAmount } = + options; + + const subject = `Payment Amount Mismatch - ${fundraiserName}`; + const expectedFormatted = expectedAmount.toFixed(2); + const paidFormatted = paidAmount.toFixed(2); + const instruction = paidAmount.lessThan(expectedAmount) + ? "Please send the remaining balance via Venmo so we can confirm your order." + : "You paid more than the order total, so the organization will follow up with you about the difference."; + + const text = ` + Hi ${buyer.name}, + + We received your Venmo payment of $${paidFormatted} for order #${orderId} (${fundraiserName}), but your order total is $${expectedFormatted}. + + ${instruction} If you believe this is a mistake, reply to this email or contact the organization directly. + + Thank you, + The CURaise Team + `; + + const html = ` +

Payment Amount Mismatch

+ +

Hi ${escapeHtml(buyer.name)},

+ +

We received your Venmo payment of $${paidFormatted} for order #${orderId} (${escapeHtml(fundraiserName)}), but your order total is $${expectedFormatted}.

+ +

${instruction} If you believe this is a mistake, reply to this email or contact the organization directly.

+ +

Thank you,
+ The CURaise Team

+ `; + + return sendEmail({ + to: buyer.email, + subject, + text, + html, + }); +}; + /** * Send pickup reminder emails to buyers of fundraisers that are occurring tomorrow */ diff --git a/common/schemas/order.ts b/common/schemas/order.ts index f5de3eba..8ce43471 100644 --- a/common/schemas/order.ts +++ b/common/schemas/order.ts @@ -2,12 +2,15 @@ import { z } from "zod"; import { UserSchema } from "./user"; import { BasicFundraiserSchema, ReferralSchema } from "./fundraiser"; import { CompleteItemSchema } from "./item"; +import { MoneySchema } from "./decimal"; export const BasicOrderSchema = z.object({ id: z.string().uuid(), paymentMethod: z.enum(["VENMO", "ZELLE", "OTHER"]), paymentStatus: z.enum(["UNVERIFIABLE", "PENDING", "CONFIRMED"]), pickedUp: z.boolean(), + paidAmount: MoneySchema.nullish(), + paymentMismatchAt: z.coerce.date().nullish(), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), diff --git a/frontend/src/app/seller/order/[id]/page.tsx b/frontend/src/app/seller/order/[id]/page.tsx index 19e35b97..402aad19 100644 --- a/frontend/src/app/seller/order/[id]/page.tsx +++ b/frontend/src/app/seller/order/[id]/page.tsx @@ -16,7 +16,14 @@ import { import { PaymentStatusBadge } from "@/components/custom/PaymentStatusBadge"; import { PickupStatusBadge } from "@/components/custom/PickupStatusBadge"; import { OrderActionButtons } from "@/components/custom/OrderActionButtons"; -import { ArrowLeft, CreditCard, ShoppingBag, User, CalendarIcon } from "lucide-react"; +import { + ArrowLeft, + CreditCard, + ShoppingBag, + User, + CalendarIcon, + TriangleAlert, +} from "lucide-react"; import { Separator } from "@/components/ui/separator"; import { serverFetch } from "@/lib/fetcher"; @@ -132,6 +139,33 @@ export default async function OrderPage({ + {/* Payment Mismatch Warning */} + {order.paymentMismatchAt && order.paidAmount != null && ( + + +
+ +
+

Payment Amount Mismatch

+

+ Buyer paid ${Decimal(order.paidAmount).toFixed(2)} via + Venmo, but the order total is ${orderTotal}. +

+

+ Flagged{" "} + + {order.paymentStatus !== "CONFIRMED" && + ". Confirm payment manually once the difference is resolved."} +

+
+
+
+
+ )} + {/* Order Status Card*/}