Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "paid_amount" MONEY,
ADD COLUMN "payment_mismatch_at" TIMESTAMP(3);
14 changes: 8 additions & 6 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions backend/src/__tests__/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const createMockOrder = (overrides?: Partial<Order>): 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,
Expand Down
90 changes: 90 additions & 0 deletions backend/src/api/email/__tests__/email.services.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
});
59 changes: 30 additions & 29 deletions backend/src/api/email/email.services.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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: ${
Expand Down
147 changes: 147 additions & 0 deletions backend/src/api/order/__tests__/order.services.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
})
);
});
});
});
Loading
Loading