Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/pink-rockets-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trizum/pwa": minor
---

Add a debt transfer flow that lets users move their own debt from one party to another from the balances screen, including the streamlined selection and review experience.
200 changes: 200 additions & 0 deletions packages/pwa/e2e/debt-transfer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { ExpensePage } from "./pages/expense.page";
import { PartyPage } from "./pages/party.page";
import { TransferDebtPage } from "./pages/transfer-debt.page";
import {
createDebtTransferDestinationFixture,
createSettlementPartyFixture,
debtTransferJourney,
defaultParticipants,
} from "./harness/scenarios";
import { expect, test } from "./harness/trizum.fixture";

test.describe("Debt transfer", () => {
test("moves a debt from one joined party to another through balances", async ({
harness,
page,
}) => {
const expensePage = new ExpensePage(page);
const originPartyPage = new PartyPage(page);
const destinationPartyPage = new PartyPage(page);
const transferDebtPage = new TransferDebtPage(page);
const originAction = {
actionLabel: "Pay" as const,
fromLabel: `${defaultParticipants.blair.name} (me)`,
toLabel: defaultParticipants.alex.name,
};
const destinationAction = {
actionLabel: "Pay" as const,
fromLabel: `${debtTransferJourney.destinationMemberParticipant.name} (me)`,
toLabel: debtTransferJourney.destinationCreditorParticipant.name,
};

const [originParty, destinationParty] =
await test.step("seed both parties in one browser boot", async () =>
harness.seedParties([
createSettlementPartyFixture(),
createDebtTransferDestinationFixture(),
]));

await test.step("join both parties in the local party list", async () => {
await harness.seedPartyList({
username: "Harness User",
phone: "",
parties: {
[originParty.partyId]: true,
[destinationParty.partyId]: true,
},
participantInParties: {
[originParty.partyId]: debtTransferJourney.originMemberParticipantId,
[destinationParty.partyId]:
debtTransferJourney.destinationMemberParticipant.id,
},
});
});

await test.step("open balances in the origin party and confirm the transfer action is available", async () => {
await harness.navigate(`/party/${originParty.partyId}?tab=balances`);
await originPartyPage.expectLoaded(
originParty.partyId,
debtTransferJourney.originPartyName,
);
await originPartyPage.expectSettlementActionVisible(originAction);
await originPartyPage.expectSettlementActionButtonVisible(
originAction,
"Transfer to another party",
);
});

await test.step("choose the recommended destination creditor", async () => {
await originPartyPage.openSettlementActionButton(
originAction,
"Transfer to another party",
);

await transferDebtPage.expectLoaded();
await transferDebtPage.expectSearchParams({
amount: "3000",
fromId: defaultParticipants.blair.id,
toId: defaultParticipants.alex.id,
});
await transferDebtPage.expectParticipantStep();
await transferDebtPage.expectRecommendedParticipant(
debtTransferJourney.destinationCreditorParticipant.name,
);
await transferDebtPage.chooseParticipant(
debtTransferJourney.destinationCreditorParticipant.name,
);
});

await test.step("complete the transfer and settle the origin party balance", async () => {
await transferDebtPage.completeTransfer();
await expensePage.expectLoaded("Debt transfer to another party");

await page.goBack();
await expect(page).toHaveURL(
new RegExp(`/party/${originParty.partyId}\\?tab=balances(?:&.*)?$`),
);
await originPartyPage.expectSettlementActionRemoved(originAction);
await originPartyPage.expectFullySettled();
});

await test.step("show the transferred debt as a new balance action in the destination party", async () => {
await harness.navigate(`/party/${destinationParty.partyId}?tab=balances`);
await destinationPartyPage.expectLoaded(
destinationParty.partyId,
debtTransferJourney.destinationPartyName,
);
await destinationPartyPage.expectSettlementActionVisible(
destinationAction,
);
});
});

test("skips member selection when the destination party has one creditor", async ({
harness,
page,
}) => {
const originPartyPage = new PartyPage(page);
const transferDebtPage = new TransferDebtPage(page);
const originAction = {
actionLabel: "Pay" as const,
fromLabel: `${defaultParticipants.blair.name} (me)`,
toLabel: defaultParticipants.alex.name,
};

const [originParty, destinationParty] = await harness.seedParties([
createSettlementPartyFixture(),
createDebtTransferDestinationFixture({
includeExtraParticipant: false,
}),
]);

await harness.seedPartyList({
username: "Harness User",
phone: "",
parties: {
[originParty.partyId]: true,
[destinationParty.partyId]: true,
},
participantInParties: {
[originParty.partyId]: debtTransferJourney.originMemberParticipantId,
[destinationParty.partyId]:
debtTransferJourney.destinationMemberParticipant.id,
},
});

await harness.navigate(`/party/${originParty.partyId}?tab=balances`);
await originPartyPage.openSettlementActionButton(
originAction,
"Transfer to another party",
);

await transferDebtPage.expectLoaded();
await transferDebtPage.expectConfirmationStep();
});

test("hides the transfer action when no destination can receive the debt", async ({
harness,
page,
}) => {
const originPartyPage = new PartyPage(page);
const originAction = {
actionLabel: "Pay" as const,
fromLabel: `${defaultParticipants.blair.name} (me)`,
toLabel: defaultParticipants.alex.name,
};

const [originParty, destinationParty] = await harness.seedParties([
createSettlementPartyFixture(),
createDebtTransferDestinationFixture({
includeCreditor: false,
includeExtraParticipant: false,
}),
]);

await harness.seedPartyList({
username: "Harness User",
phone: "",
parties: {
[originParty.partyId]: true,
[destinationParty.partyId]: true,
},
participantInParties: {
[originParty.partyId]: debtTransferJourney.originMemberParticipantId,
[destinationParty.partyId]:
debtTransferJourney.destinationMemberParticipant.id,
},
});

await harness.navigate(`/party/${originParty.partyId}?tab=balances`);
await originPartyPage.expectLoaded(
originParty.partyId,
debtTransferJourney.originPartyName,
);
await originPartyPage.expectSettlementActionVisible(originAction);
await originPartyPage.expectSettlementActionButtonHidden(
originAction,
"Transfer to another party",
);
});
});
53 changes: 53 additions & 0 deletions packages/pwa/e2e/harness/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export const expenseLogJourney = {
newExpenseTitle: "Late checkout snacks",
} as const;

export const debtTransferJourney = {
originPartyName: "Weekend trip",
destinationPartyName: "City break",
originMemberParticipantId: defaultParticipants.blair.id,
destinationMemberParticipant: {
id: "participant-blair-city",
name: "Blair Downtown",
},
destinationCreditorParticipant: {
id: "participant-alex-smith",
name: "Alex Smith",
},
} as const;

export function createPartyFixture() {
return {
party: {
Expand Down Expand Up @@ -152,3 +166,42 @@ export function createExpenseLogFixture(
})),
};
}

export function createDebtTransferDestinationFixture({
includeCreditor = true,
includeExtraParticipant = true,
}: {
includeCreditor?: boolean;
includeExtraParticipant?: boolean;
} = {}) {
return {
party: {
type: "party" as const,
name: debtTransferJourney.destinationPartyName,
symbol: "🌆",
description: "Costs for the city break.",
currency: "EUR" as const,
participants: {
[debtTransferJourney.destinationMemberParticipant.id]: {
...debtTransferJourney.destinationMemberParticipant,
},
...(includeCreditor
? {
[debtTransferJourney.destinationCreditorParticipant.id]: {
...debtTransferJourney.destinationCreditorParticipant,
},
}
: {}),
...(includeExtraParticipant
? {
[defaultParticipants.casey.id]: {
...defaultParticipants.casey,
},
}
: {}),
},
},
expenses: [],
photos: [],
};
}
65 changes: 49 additions & 16 deletions packages/pwa/e2e/harness/trizum.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export interface BrowserHarness {
joinUrl: string;
partyId: string;
}>;
seedParties(fixtures: unknown[]): Promise<
{
joinCode: string;
joinUrl: string;
partyId: string;
}[]
>;
seedPartyList(seed: PartyListSeed): Promise<{
partyListId: string;
}>;
Expand Down Expand Up @@ -91,16 +98,28 @@ function createOfflinePath(pathname = "/") {
}

function createBrowserHarness(page: Page): BrowserHarness {
async function hasInternalHooks() {
return page
.evaluate(() => {
const internalWindow = window as Partial<InternalHarnessWindow>;
return (
typeof internalWindow.__internal_createPartyFromMigrationData ===
"function" &&
typeof internalWindow.__internal_seedPartyListState === "function" &&
typeof internalWindow.__internal_readPartyListState === "function"
);
})
.catch(() => false);
}

async function goto(path = "/") {
await page.goto(createOfflinePath(path));
}

async function navigate(path: string) {
const nextPath = createOfflinePath(path).replace(
/\?__internal_offline_only=true$/,
"",
);
const nextUrl = new URL(nextPath, "http://trizum.local");
const nextUrl = new URL(createOfflinePath(path), "http://trizum.local");
nextUrl.searchParams.delete("__internal_offline_only");
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;

await page.evaluate((targetPath) => {
const url = new URL(targetPath, window.location.origin);
Expand Down Expand Up @@ -130,22 +149,16 @@ function createBrowserHarness(page: Page): BrowserHarness {
async function waitForInternalHooks() {
await expect
.poll(async () => {
return page.evaluate(() => {
const internalWindow = window as Partial<InternalHarnessWindow>;
return (
typeof internalWindow.__internal_createPartyFromMigrationData ===
"function" &&
typeof internalWindow.__internal_seedPartyListState ===
"function" &&
typeof internalWindow.__internal_readPartyListState === "function"
);
});
return hasInternalHooks();
})
.toBe(true);
}

async function bootstrapForSeeding() {
await goto("/");
if (!(await hasInternalHooks())) {
await goto("/");
}

await waitForInternalHooks();
}

Expand All @@ -168,6 +181,25 @@ function createBrowserHarness(page: Page): BrowserHarness {
return buildPartySeedResult(partyId);
}

async function seedParties(fixtures: unknown[]) {
await bootstrapForSeeding();

const partyIds = await page.evaluate(async (partyFixtures) => {
const internalWindow = window as InternalHarnessWindow;
const nextPartyIds: string[] = [];

for (const fixture of partyFixtures) {
nextPartyIds.push(
await internalWindow.__internal_createPartyFromMigrationData(fixture),
);
}

return nextPartyIds;
}, fixtures);

return partyIds.map(buildPartySeedResult);
}

async function seedPartyList(seed: PartyListSeed) {
await bootstrapForSeeding();
return writePartyList(seed);
Expand Down Expand Up @@ -262,6 +294,7 @@ function createBrowserHarness(page: Page): BrowserHarness {
navigate,
gotoParty,
seedParty,
seedParties,
seedPartyList,
seedJoinableParty,
joinSeededParty,
Expand Down
Loading
Loading