Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion apps/web/lib/api/sales/calculate-sale-earnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const calculateSaleEarnings = ({
if (reward.type === "flat") {
return sale.quantity * amount;
} else if (reward.type === "percentage") {
return sale.amount * (amount / 100);
return Math.round((sale.amount * amount) / 100);
}

return 0;
Expand Down
144 changes: 144 additions & 0 deletions apps/web/playwright/api/commissions/commissions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { createId } from "@/lib/api/create-id";
import { conn } from "@/lib/planetscale";
import { prisma } from "@/lib/prisma";
import type { CommissionResponse, EnrolledPartnerProps } from "@/lib/types";
import { expect } from "@playwright/test";
import { Prisma } from "@prisma/client";
import { randomCustomer, randomName, randomPartnerEmail } from "../../utils";
import { test, type ApiClient } from "../fixtures";

test.describe.configure({
mode: "parallel",
});

async function createPartner(
api: ApiClient,
overrides: Record<string, unknown> = {},
) {
return api.post<EnrolledPartnerProps>("/api/partners", {
name: randomName(),
email: randomPartnerEmail(),
...overrides,
});
}

async function cleanupRoundingFixture({
partnerId,
rewardId,
customerId,
commissionId,
}: {
partnerId?: string;
rewardId?: string;
customerId?: string;
commissionId?: string;
}) {
if (commissionId) {
await prisma.commission.deleteMany({ where: { id: commissionId } });
}

if (customerId) {
await prisma.customer.deleteMany({ where: { id: customerId } });
}

if (partnerId) {
await prisma.link.deleteMany({ where: { partnerId } });
await prisma.programEnrollment.deleteMany({ where: { partnerId } });
await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]);
}

if (rewardId) {
await prisma.reward.deleteMany({ where: { id: rewardId } });
}
}

test("PATCH /commissions/:id – 3¢ sale at 20% rounds to 1¢", async ({
api,
workspace,
program,
}) => {
let partnerId: string | undefined;
let rewardId: string | undefined;
let customerId: string | undefined;
let commissionId: string | undefined;

try {
const { status: partnerStatus, data: partner } = await createPartner(api);
expect(partnerStatus).toEqual(201);
partnerId = partner.id;

const reward = await prisma.reward.create({
data: {
id: createId({ prefix: "rw_" }),
programId: program.id,
event: "sale",
type: "percentage",
amountInPercentage: new Prisma.Decimal(20),
},
});
rewardId = reward.id;

await prisma.programEnrollment.update({
where: {
partnerId_programId: {
partnerId,
programId: program.id,
},
},
data: {
saleRewardId: reward.id,
},
});

const customer = randomCustomer();
customerId = createId({ prefix: "cus_" });

await prisma.customer.create({
data: {
id: customerId,
name: customer.name,
email: customer.email,
externalId: customer.externalId,
country: customer.country,
projectId: workspace.id,
programId: program.id,
partnerId,
},
});

commissionId = createId({ prefix: "cm_" });

await prisma.commission.create({
data: {
id: commissionId,
programId: program.id,
partnerId,
customerId,
rewardId,
type: "sale",
amount: 100,
earnings: 20,
quantity: 1,
currency: "usd",
status: "pending",
},
});

const { status, data } = await api.patch<CommissionResponse>(
`/api/commissions/${commissionId}`,
{ saleAmount: 3 },
);

expect(status).toEqual(200);
expect(data.amount).toEqual(3);
expect(data.earnings).toEqual(1);
expect(data.status).toEqual("pending");
} finally {
await cleanupRoundingFixture({
partnerId,
rewardId,
customerId,
commissionId,
});
}
});
149 changes: 149 additions & 0 deletions apps/web/tests/sales/calculate-sale-earnings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings";
import { describe, expect, test } from "vitest";

const percentageEarnings = (
saleAmount: number,
percent: number,
quantity = 1,
) =>
calculateSaleEarnings({
reward: {
type: "percentage",
amountInCents: null,
amountInPercentage: percent,
},
sale: { amount: saleAmount, quantity },
});

describe("calculateSaleEarnings", () => {
describe("percentage – same as the old truncate path", () => {
test.each([
{
label: "whole cents: $10 sale at 20%",
saleAmount: 1000,
percent: 20,
expected: 200,
},
{
label: "whole cents: $10 sale at 50%",
saleAmount: 1000,
percent: 50,
expected: 500,
},
{
label: "whole cents: $19 sale at 10%",
saleAmount: 1900,
percent: 10,
expected: 190,
},
{
label: "1.4¢ truncates and rounds to the same value",
saleAmount: 7,
percent: 20,
expected: 1,
},
{
label: "0.4¢ stays 0 (below half a cent)",
saleAmount: 2,
percent: 20,
expected: 0,
},
{
label: "zero sale amount",
saleAmount: 0,
percent: 20,
expected: 0,
},
{
label: "zero percent",
saleAmount: 1000,
percent: 0,
expected: 0,
},
])("$label → $expected", ({ saleAmount, percent, expected }) => {
expect(percentageEarnings(saleAmount, percent)).toBe(expected);
});
});

describe("percentage – half-up (new vs old truncate)", () => {
test.each([
{
label: "3¢ sale at 20% (ticket: 0.6¢, used to store 0)",
saleAmount: 3,
percent: 20,
expected: 1,
},
{
label: "15¢ sale at 10% (1.5¢, used to store 1)",
saleAmount: 15,
percent: 10,
expected: 2,
},
{
label: "1¢ sale at 50% (0.5¢, used to store 0)",
saleAmount: 1,
percent: 50,
expected: 1,
},
{
label: "500¢ at 2.9% (14.5¢; float 2.9/100 used to round to 14)",
saleAmount: 500,
percent: 2.9,
expected: 15,
},
])("$label → $expected", ({ saleAmount, percent, expected }) => {
expect(percentageEarnings(saleAmount, percent)).toBe(expected);
});
});

test("percentage ignores quantity (uses sale amount only)", () => {
expect(percentageEarnings(1000, 20, 5)).toBe(200);
});

describe("flat", () => {
test.each([
{
label: "single sale",
amountInCents: 5000,
quantity: 1,
expected: 5000,
},
{
label: "quantity multiplies the flat amount",
amountInCents: 500,
quantity: 2,
expected: 1000,
},
{
label: "zero quantity",
amountInCents: 500,
quantity: 0,
expected: 0,
},
])("$label → $expected", ({ amountInCents, quantity, expected }) => {
expect(
calculateSaleEarnings({
reward: {
type: "flat",
amountInCents,
amountInPercentage: null,
},
sale: { amount: 1000, quantity },
}),
).toBe(expected);
});
});

test("returns 0 when reward type is neither flat nor percentage", () => {
expect(
calculateSaleEarnings({
reward: {
type: "unknown" as "flat",
amountInCents: 500,
amountInPercentage: 20,
},
sale: { amount: 1000, quantity: 1 },
}),
).toBe(0);
});
});
Loading