diff --git a/.changeset/pink-rockets-try.md b/.changeset/pink-rockets-try.md new file mode 100644 index 00000000..068e1029 --- /dev/null +++ b/.changeset/pink-rockets-try.md @@ -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. diff --git a/packages/pwa/e2e/debt-transfer.spec.ts b/packages/pwa/e2e/debt-transfer.spec.ts new file mode 100644 index 00000000..43c0766f --- /dev/null +++ b/packages/pwa/e2e/debt-transfer.spec.ts @@ -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", + ); + }); +}); diff --git a/packages/pwa/e2e/harness/scenarios.ts b/packages/pwa/e2e/harness/scenarios.ts index 777e34dd..dcb08d43 100644 --- a/packages/pwa/e2e/harness/scenarios.ts +++ b/packages/pwa/e2e/harness/scenarios.ts @@ -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: { @@ -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: [], + }; +} diff --git a/packages/pwa/e2e/harness/trizum.fixture.ts b/packages/pwa/e2e/harness/trizum.fixture.ts index 85a90c44..444e5e4e 100644 --- a/packages/pwa/e2e/harness/trizum.fixture.ts +++ b/packages/pwa/e2e/harness/trizum.fixture.ts @@ -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; }>; @@ -91,16 +98,28 @@ function createOfflinePath(pathname = "/") { } function createBrowserHarness(page: Page): BrowserHarness { + async function hasInternalHooks() { + return page + .evaluate(() => { + const internalWindow = window as Partial; + 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); @@ -130,22 +149,16 @@ function createBrowserHarness(page: Page): BrowserHarness { async function waitForInternalHooks() { await expect .poll(async () => { - return page.evaluate(() => { - const internalWindow = window as Partial; - 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(); } @@ -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); @@ -262,6 +294,7 @@ function createBrowserHarness(page: Page): BrowserHarness { navigate, gotoParty, seedParty, + seedParties, seedPartyList, seedJoinableParty, joinSeededParty, diff --git a/packages/pwa/e2e/pages/party.page.ts b/packages/pwa/e2e/pages/party.page.ts index 85b9ffca..6bca7fdc 100644 --- a/packages/pwa/e2e/pages/party.page.ts +++ b/packages/pwa/e2e/pages/party.page.ts @@ -128,12 +128,43 @@ export class PartyPage { ).toBeVisible(); } + async expectSettlementActionButtonVisible( + action: SettlementAction, + buttonName: string, + ) { + await expect( + this.settlementActionCard(action).getByRole("button", { + name: buttonName, + }), + ).toBeVisible(); + } + + async expectSettlementActionButtonHidden( + action: SettlementAction, + buttonName: string, + ) { + await expect( + this.settlementActionCard(action).getByRole("button", { + name: buttonName, + }), + ).toHaveCount(0); + } + async openSettlementAction(action: SettlementAction) { await this.settlementActionCard(action) .getByRole("button", { name: action.actionLabel }) .click(); } + async openSettlementActionButton( + action: SettlementAction, + buttonName: string, + ) { + await this.settlementActionCard(action) + .getByRole("button", { name: buttonName }) + .click(); + } + async expectSettlementActionRemoved(action: SettlementAction) { await expect(this.settlementActionCard(action)).toHaveCount(0); } @@ -143,7 +174,9 @@ export class PartyPage { await expect(this.debtFreeMessage).toBeVisible(); await expect(this.nobodyOwesYouMessage).toBeVisible(); await expect( - this.page.getByRole("button", { name: /^(Pay|Mark as paid)$/ }), + this.page.getByRole("button", { + name: /^(Pay|Mark as paid|Transfer to another party)$/, + }), ).toHaveCount(0); } diff --git a/packages/pwa/e2e/pages/transfer-debt.page.ts b/packages/pwa/e2e/pages/transfer-debt.page.ts new file mode 100644 index 00000000..2dea67e4 --- /dev/null +++ b/packages/pwa/e2e/pages/transfer-debt.page.ts @@ -0,0 +1,85 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export class TransferDebtPage { + readonly page: Page; + readonly partyStep: Locator; + readonly participantStep: Locator; + readonly confirmationStep: Locator; + readonly confirmTransferButton: Locator; + + constructor(page: Page) { + this.page = page; + this.partyStep = page.getByRole("region", { + name: "Choose a destination party", + }); + this.participantStep = page.getByRole("region", { + name: "Choose who receives it", + }); + this.confirmationStep = page.getByRole("region", { + name: "Confirm transfer", + }); + this.confirmTransferButton = page.getByRole("button", { + name: "Confirm transfer", + }); + } + + async expectLoaded() { + await expect(this.page).toHaveURL(/\/party\/[^/]+\/transfer-debt\?.+/); + await expect( + this.page.getByRole("heading", { + name: /^(Confirm transfer|Transfer debt)$/, + }), + ).toBeVisible(); + } + + async expectSearchParams(params: { + amount: string; + fromId: string; + toId: string; + }) { + await expect + .poll(() => { + const url = new URL(this.page.url()); + return { + amount: url.searchParams.get("amount"), + fromId: url.searchParams.get("fromId"), + toId: url.searchParams.get("toId"), + }; + }) + .toEqual(params); + } + + async expectParticipantStep() { + await expect(this.participantStep).toBeVisible(); + await expect(this.partyStep).toBeHidden(); + await expect(this.page.getByText("Choose who receives it")).toBeVisible(); + } + + async expectConfirmationStep() { + await expect(this.confirmationStep).toBeVisible(); + await expect(this.partyStep).toBeHidden(); + await expect(this.participantStep).toBeHidden(); + await expect(this.page.getByText("Moved to")).toBeVisible(); + } + + async expectRecommendedParticipant(participantName: string) { + const participantRow = this.participantStep.locator("button").first(); + + await expect(participantRow).toContainText(participantName); + await expect(participantRow.getByLabel("Recommended match")).toBeVisible(); + } + + async chooseParticipant(participantName: string) { + await this.participantStep + .locator("button") + .filter({ hasText: participantName }) + .first() + .click(); + await expect(this.confirmationStep).toBeVisible(); + } + + async completeTransfer() { + await this.expectConfirmationStep(); + await this.confirmTransferButton.click(); + } +} diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index c9c1f7e7..365a2838 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -15,17 +15,18 @@ msgstr "" #: src/routes/party_.$partyId.pay.tsx:101 #: src/routes/party_.$partyId.pay.tsx:107 -#: src/routes/party.$partyId.tsx:973 -#: src/routes/party.$partyId.tsx:979 +#: src/routes/party.$partyId.tsx:983 +#: src/routes/party.$partyId.tsx:989 msgid "(me)" msgstr "(me)" -#: src/routes/party.$partyId.tsx:442 +#: src/routes/party.$partyId.tsx:443 msgid "[DEV] Create expenses" msgstr "[DEV] Create expenses" #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 +#: src/routes/party_.$partyId.transfer-debt.tsx:710 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {and # other} other {and # others}}" @@ -49,6 +50,14 @@ msgstr "© {currentYear} trizum" msgid "© 2024 trizum. Open source software." msgstr "© 2024 trizum. Open source software." +#: src/routes/party_.$partyId.transfer-debt.tsx:856 +msgid "<0>{destinationCreditorName} is owed by <1>{destinationDebtorName}" +msgstr "<0>{destinationCreditorName} is owed by <1>{destinationDebtorName}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:839 +msgid "<0>{fromName} stops owing <1>{toName}" +msgstr "<0>{fromName} stops owing <1>{toName}" + #: src/lib/validation.ts:66 msgid "A name for the participant is required" msgstr "A name for the participant is required" @@ -82,8 +91,8 @@ msgstr "ADB Unit of Account" msgid "Add" msgstr "Add" -#: src/routes/party.$partyId.tsx:459 -#: src/routes/party.$partyId.tsx:467 +#: src/routes/party.$partyId.tsx:460 +#: src/routes/party.$partyId.tsx:468 msgid "Add an expense" msgstr "Add an expense" @@ -92,7 +101,7 @@ msgid "Add expenses to this party to unlock totals, rankings, and individual spe msgstr "Add expenses to this party to unlock totals, rankings, and individual spend." #: src/routes/index.tsx:231 -#: src/routes/party.$partyId.tsx:425 +#: src/routes/party.$partyId.tsx:426 msgid "Add or create" msgstr "Add or create" @@ -259,15 +268,15 @@ msgstr "Bahamian Dollar" msgid "Bahraini Dinar" msgstr "Bahraini Dinar" -#: src/routes/party.$partyId.tsx:211 +#: src/routes/party.$partyId.tsx:212 msgid "Balance, Highest First" msgstr "Balance, Highest First" -#: src/routes/party.$partyId.tsx:193 +#: src/routes/party.$partyId.tsx:194 msgid "Balance, Lowest First" msgstr "Balance, Lowest First" -#: src/routes/party.$partyId.tsx:363 +#: src/routes/party.$partyId.tsx:364 msgid "Balances" msgstr "Balances" @@ -423,10 +432,18 @@ msgstr "Chilean Unit of Account (UF)" msgid "Chinese Yuan" msgstr "Chinese Yuan" +#: src/routes/party_.$partyId.transfer-debt.tsx:540 +msgid "Choose a destination party" +msgstr "Choose a destination party" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Choose the currency for expenses in this party" +#: src/routes/party_.$partyId.transfer-debt.tsx:489 +msgid "Choose who receives it" +msgstr "Choose who receives it" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Clear all" @@ -484,6 +501,16 @@ msgstr "Complete your profile" msgid "Configure Profile" msgstr "Configure Profile" +#: src/routes/party_.$partyId.transfer-debt.tsx:341 +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +#: src/routes/party_.$partyId.transfer-debt.tsx:467 +msgid "Confirm transfer" +msgstr "Confirm transfer" + +#: src/routes/party_.$partyId.transfer-debt.tsx:460 +msgid "Confirming..." +msgstr "Confirming..." + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Congolese Franc" @@ -517,6 +544,10 @@ msgstr "Create a new Party" msgid "Created with ❤️ by the open source community. Special thanks to all contributors who made this project possible." msgstr "Created with ❤️ by the open source community. Special thanks to all contributors who made this project possible." +#: src/routes/party_.$partyId.transfer-debt.tsx:487 +msgid "Creditor" +msgstr "Creditor" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Credits" @@ -564,6 +595,10 @@ msgstr "Danish Krone" msgid "Date" msgstr "Date" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Debt being moved" +msgstr "Debt being moved" + #: src/routes/party_.$partyId.pay.tsx:70 msgid "Debt settled between {0} and {1}!" msgstr "Debt settled between {0} and {1}!" @@ -572,6 +607,19 @@ msgstr "Debt settled between {0} and {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "Debt settled between {fromName} and {toName}!" +#: src/routes/party_.$partyId.transfer-debt.tsx:386 +msgid "Debt transfer from another party" +msgstr "Debt transfer from another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:385 +msgid "Debt transfer to another party" +msgstr "Debt transfer to another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:343 +#: src/routes/party_.$partyId.transfer-debt.tsx:961 +msgid "Debt transferred" +msgstr "Debt transferred" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Decimal point" @@ -589,6 +637,10 @@ msgstr "Description" msgid "Description must be less than 500 characters" msgstr "Description must be less than 500 characters" +#: src/routes/party_.$partyId.transfer-debt.tsx:538 +msgid "Destination party" +msgstr "Destination party" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "Did you know?" @@ -711,7 +763,7 @@ msgstr "Expense Split Mode" msgid "Expense updated" msgstr "Expense updated" -#: src/routes/party.$partyId.tsx:357 +#: src/routes/party.$partyId.tsx:358 msgid "Expenses" msgstr "Expenses" @@ -751,6 +803,10 @@ msgstr "Failed to load licenses. Please try again later." msgid "Failed to mark expense as paid" msgstr "Failed to mark expense as paid" +#: src/routes/party_.$partyId.transfer-debt.tsx:393 +msgid "Failed to transfer debt" +msgstr "Failed to transfer debt" + #: src/routes/party_.$partyId.expense.$expenseId_.edit.tsx:171 msgid "Failed to update expense" msgstr "Failed to update expense" @@ -820,9 +876,14 @@ msgid "Go back" msgstr "Go back" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:589 msgid "Go Back" msgstr "Go Back" +#: src/routes/party_.$partyId.transfer-debt.tsx:311 +msgid "Go back and try again from the balances list." +msgstr "Go back and try again from the balances list." + #: src/routes/migrate_.tricount.tsx:315 msgid "Go back to home" msgstr "Go back to home" @@ -859,7 +920,7 @@ msgstr "Have an idea to improve trizum? We'd love to hear it." msgid "Heads up!" msgstr "Heads up!" -#: src/routes/party.$partyId.tsx:772 +#: src/routes/party.$partyId.tsx:775 msgid "Here is a list of operations you and other party members can do to balance your position." msgstr "Here is a list of operations you and other party members can do to balance your position." @@ -892,7 +953,7 @@ msgstr "How do you want to call this party?" msgid "How does offline sync work?" msgstr "How does offline sync work?" -#: src/routes/party.$partyId.tsx:768 +#: src/routes/party.$partyId.tsx:771 msgid "How should I balance?" msgstr "How should I balance?" @@ -916,7 +977,7 @@ msgstr "ID is required" msgid "Image must be smaller than 10MB" msgstr "Image must be smaller than 10MB" -#: src/routes/party.$partyId.tsx:660 +#: src/routes/party.$partyId.tsx:661 msgid "Impact on my balance: <0/>" msgstr "Impact on my balance: <0/>" @@ -1063,7 +1124,7 @@ msgstr "Last used" msgid "Last year" msgstr "Last year" -#: src/routes/party.$partyId.tsx:341 +#: src/routes/party.$partyId.tsx:342 msgid "Leave party" msgstr "Leave party" @@ -1137,7 +1198,7 @@ msgstr "Manage the participants list. Existing members can only be archived." #: src/routes/party_.$partyId.pay.tsx:93 #: src/routes/party_.$partyId.pay.tsx:127 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Mark as paid" msgstr "Mark as paid" @@ -1163,7 +1224,7 @@ msgstr "Me" #: src/components/ExpenseEditor.tsx:268 #: src/routes/index.tsx:117 #: src/routes/party_.$partyId.expense.$expenseId.tsx:103 -#: src/routes/party.$partyId.tsx:225 +#: src/routes/party.$partyId.tsx:226 msgid "Menu" msgstr "Menu" @@ -1190,6 +1251,7 @@ msgid "Migration successful" msgstr "Migration successful" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:209 msgid "Missing search params" msgstr "Missing search params" @@ -1213,6 +1275,10 @@ msgstr "Move cursor left" msgid "Move cursor right" msgstr "Move cursor right" +#: src/routes/party_.$partyId.transfer-debt.tsx:853 +msgid "Moved to" +msgstr "Moved to" + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Mozambican Metical" @@ -1231,7 +1297,7 @@ msgstr "Myanma Kyat" #: src/routes/new.tsx:186 #: src/routes/party_.$partyId.settings.tsx:166 -#: src/routes/party.$partyId.tsx:175 +#: src/routes/party.$partyId.tsx:176 msgid "Name" msgstr "Name" @@ -1304,6 +1370,10 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" +#: src/routes/party_.$partyId.transfer-debt.tsx:532 +msgid "No destination party available" +msgstr "No destination party available" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No emoji found" @@ -1325,7 +1395,11 @@ msgstr "No spending stats yet" msgid "No updates available" msgstr "No updates available" -#: src/routes/party.$partyId.tsx:854 +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Nobody else is available in this party" +msgstr "Nobody else is available in this party" + +#: src/routes/party.$partyId.tsx:858 msgid "Nobody owes you money!" msgstr "Nobody owes you money!" @@ -1365,10 +1439,14 @@ msgstr "Older parties stay tucked away, not gone" msgid "Omani Rial" msgstr "Omani Rial" -#: src/routes/party.$partyId.tsx:266 +#: src/routes/party.$partyId.tsx:267 msgid "Only show your expenses" msgstr "Only show your expenses" +#: src/routes/party_.$partyId.transfer-debt.tsx:321 +msgid "Only your own debt can be transferred" +msgstr "Only your own debt can be transferred" + #: src/routes/index.tsx:377 msgid "Open archived parties" msgstr "Open archived parties" @@ -1393,6 +1471,10 @@ msgstr "Open last party on launch" msgid "Open parenthesis" msgstr "Open parenthesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:976 +msgid "Opening updated expense…" +msgstr "Opening updated expense…" + #: src/components/ExpenseEditor.tsx:201 msgid "Optional description for this expense" msgstr "Optional description for this expense" @@ -1401,12 +1483,12 @@ msgstr "Optional description for this expense" msgid "or enter code" msgstr "or enter code" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Other operations" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party.$partyId.tsx:976 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1532,15 +1614,15 @@ msgid "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc12 msgstr "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc123), or the direct key" #: src/routes/party_.$partyId.pay.tsx:93 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Pay" msgstr "Pay" -#: src/routes/party.$partyId.tsx:831 +#: src/routes/party.$partyId.tsx:835 msgid "People that owe you money" msgstr "People that owe you money" -#: src/routes/party.$partyId.tsx:262 +#: src/routes/party.$partyId.tsx:263 msgid "Personal mode" msgstr "Personal mode" @@ -1644,6 +1726,10 @@ msgstr "Real-Time Sync" msgid "Receipt Attachments" msgstr "Receipt Attachments" +#: src/routes/party_.$partyId.transfer-debt.tsx:776 +msgid "Recommended match" +msgstr "Recommended match" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Reload" @@ -1735,7 +1821,7 @@ msgstr "Search emoji" msgid "Search emoji..." msgstr "Search emoji..." -#: src/routes/party.$partyId.tsx:294 +#: src/routes/party.$partyId.tsx:295 msgid "See spending totals and rankings" msgstr "See spending totals and rankings" @@ -1756,7 +1842,7 @@ msgid "Set up your username, avatar, and preferences" msgstr "Set up your username, avatar, and preferences" #: src/routes/index.tsx:133 -#: src/routes/party.$partyId.tsx:329 +#: src/routes/party.$partyId.tsx:330 #: src/routes/settings.tsx:91 msgid "Settings" msgstr "Settings" @@ -1765,13 +1851,17 @@ msgstr "Settings" msgid "Settings saved" msgstr "Settings saved" +#: src/routes/party_.$partyId.transfer-debt.tsx:836 +msgid "Settled in" +msgstr "Settled in" + #: src/routes/new.tsx:554 msgid "Seychellois Rupee" msgstr "Seychellois Rupee" #: src/routes/party_.$partyId.share.tsx:58 #: src/routes/party_.$partyId.share.tsx:93 -#: src/routes/party.$partyId.tsx:312 +#: src/routes/party.$partyId.tsx:313 msgid "Share party" msgstr "Share party" @@ -1827,7 +1917,7 @@ msgstr "Somali Shilling" msgid "Something went wrong" msgstr "Something went wrong" -#: src/routes/party.$partyId.tsx:162 +#: src/routes/party.$partyId.tsx:163 msgid "Sort balances" msgstr "Sort balances" @@ -1872,7 +1962,7 @@ msgstr "Start date" msgid "Start tracking expenses with your group" msgstr "Start tracking expenses with your group" -#: src/routes/party.$partyId.tsx:290 +#: src/routes/party.$partyId.tsx:291 msgid "Stats" msgstr "Stats" @@ -1972,7 +2062,7 @@ msgstr "Take photo" msgid "Tanzanian Shilling" msgstr "Tanzanian Shilling" -#: src/routes/party.$partyId.tsx:248 +#: src/routes/party.$partyId.tsx:249 msgid "Tap to change" msgstr "Tap to change" @@ -1988,6 +2078,10 @@ msgstr "Thank you for using trizum. Your feedback helps us improve!" msgid "The app works offline too!" msgstr "The app works offline too!" +#: src/routes/party_.$partyId.transfer-debt.tsx:965 +msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." +msgstr "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." + #: src/components/PartyPendingComponent.tsx:27 msgid "The party data hasn't synced to the sync server yet." msgstr "The party data hasn't synced to the sync server yet." @@ -2010,6 +2104,10 @@ msgstr "Third-Party Licenses" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "This application uses the following open source libraries and tools. Below are their licenses and attributions." +#: src/routes/party_.$partyId.transfer-debt.tsx:310 +msgid "This debt is no longer available" +msgstr "This debt is no longer available" + #: src/hooks/useMediaFileActions.ts:34 msgid "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." msgstr "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." @@ -2018,6 +2116,10 @@ msgstr "This HEIC or HEIF image could not be processed. Try another photo or exp msgid "This isn't a valid ID" msgstr "This isn't a valid ID" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "This party needs another active participant besides you to receive the transferred debt." +msgstr "This party needs another active participant besides you to receive the transferred debt." + #: src/routes/about.tsx:144 msgid "This project uses various open source libraries and tools." msgstr "This project uses various open source libraries and tools." @@ -2067,6 +2169,16 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" +#: src/routes/party_.$partyId.transfer-debt.tsx:308 +#: src/routes/party_.$partyId.transfer-debt.tsx:319 +#: src/routes/party_.$partyId.transfer-debt.tsx:344 +msgid "Transfer debt" +msgstr "Transfer debt" + +#: src/routes/party.$partyId.tsx:1047 +msgid "Transfer to another party" +msgstr "Transfer to another party" + #: src/routes/migrate_.tricount.tsx:218 msgid "Tricount key" msgstr "Tricount key" @@ -2248,7 +2360,7 @@ msgstr "View Third-Party Licenses" msgid "Viewing as {0}" msgstr "Viewing as {0}" -#: src/routes/party.$partyId.tsx:244 +#: src/routes/party.$partyId.tsx:245 msgid "Viewing as {participantName}" msgstr "Viewing as {participantName}" @@ -2313,15 +2425,23 @@ msgstr "Yes! Your data is stored locally on your device and only synced with peo msgid "You can add photos to your expenses for better tracking." msgstr "You can add photos to your expenses for better tracking." -#: src/routes/party.$partyId.tsx:127 +#: src/routes/party_.$partyId.transfer-debt.tsx:322 +msgid "You can only transfer debt from actions where you are the one who owes the money." +msgstr "You can only transfer debt from actions where you are the one who owes the money." + +#: src/routes/party.$partyId.tsx:128 msgid "You left the party!" msgstr "You left the party!" -#: src/routes/party.$partyId.tsx:792 +#: src/routes/party_.$partyId.transfer-debt.tsx:533 +msgid "You need another active party with the same currency to transfer this debt." +msgstr "You need another active party with the same currency to transfer this debt." + +#: src/routes/party.$partyId.tsx:795 msgid "You owe money to people" msgstr "You owe money to people" -#: src/routes/party.$partyId.tsx:815 +#: src/routes/party.$partyId.tsx:819 msgid "You're debt free!" msgstr "You're debt free!" diff --git a/packages/pwa/locale/es/messages.po b/packages/pwa/locale/es/messages.po index 76c71f92..f747ad80 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -15,17 +15,18 @@ msgstr "" #: src/routes/party_.$partyId.pay.tsx:101 #: src/routes/party_.$partyId.pay.tsx:107 -#: src/routes/party.$partyId.tsx:973 -#: src/routes/party.$partyId.tsx:979 +#: src/routes/party.$partyId.tsx:983 +#: src/routes/party.$partyId.tsx:989 msgid "(me)" msgstr "(yo)" -#: src/routes/party.$partyId.tsx:442 +#: src/routes/party.$partyId.tsx:443 msgid "[DEV] Create expenses" msgstr "[DEV] Crear gastos" #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 +#: src/routes/party_.$partyId.transfer-debt.tsx:710 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {y # más} other {y # más}}" @@ -45,6 +46,14 @@ msgstr "© {0} trizum" msgid "© {currentYear} trizum" msgstr "© {currentYear} trizum" +#: src/routes/party_.$partyId.transfer-debt.tsx:856 +msgid "<0>{destinationCreditorName} is owed by <1>{destinationDebtorName}" +msgstr "<1>{destinationDebtorName} debe dinero a <0>{destinationCreditorName}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:839 +msgid "<0>{fromName} stops owing <1>{toName}" +msgstr "<0>{fromName} deja de deber dinero a <1>{toName}" + #: src/lib/validation.ts:66 msgid "A name for the participant is required" msgstr "Se requiere un nombre para el participante" @@ -78,8 +87,8 @@ msgstr "Unidad de Cuenta del BAsD" msgid "Add" msgstr "Sumar" -#: src/routes/party.$partyId.tsx:459 -#: src/routes/party.$partyId.tsx:467 +#: src/routes/party.$partyId.tsx:460 +#: src/routes/party.$partyId.tsx:468 msgid "Add an expense" msgstr "Añadir un gasto" @@ -88,7 +97,7 @@ msgid "Add expenses to this party to unlock totals, rankings, and individual spe msgstr "Añade gastos a este grupo para desbloquear totales, rankings y gasto individual." #: src/routes/index.tsx:231 -#: src/routes/party.$partyId.tsx:425 +#: src/routes/party.$partyId.tsx:426 msgid "Add or create" msgstr "Añadir o crear" @@ -247,15 +256,15 @@ msgstr "Dólar Bahameño" msgid "Bahraini Dinar" msgstr "Dinar Bareiní" -#: src/routes/party.$partyId.tsx:211 +#: src/routes/party.$partyId.tsx:212 msgid "Balance, Highest First" msgstr "Balance, Mayor Primero" -#: src/routes/party.$partyId.tsx:193 +#: src/routes/party.$partyId.tsx:194 msgid "Balance, Lowest First" msgstr "Balance, Menor Primero" -#: src/routes/party.$partyId.tsx:363 +#: src/routes/party.$partyId.tsx:364 msgid "Balances" msgstr "Balance" @@ -399,10 +408,18 @@ msgstr "Unidad de Fomento Chilena (UF)" msgid "Chinese Yuan" msgstr "Yuan Chino" +#: src/routes/party_.$partyId.transfer-debt.tsx:540 +msgid "Choose a destination party" +msgstr "Elige un grupo de destino" + #: src/routes/new.tsx:255 msgid "Choose the currency for expenses in this party" msgstr "Elige la moneda para los gastos de este grupo" +#: src/routes/party_.$partyId.transfer-debt.tsx:489 +msgid "Choose who receives it" +msgstr "Elige quién la recibe" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Borrar todo" @@ -460,6 +477,16 @@ msgstr "Completa tu perfil" msgid "Configure Profile" msgstr "Configurar perfil" +#: src/routes/party_.$partyId.transfer-debt.tsx:341 +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +#: src/routes/party_.$partyId.transfer-debt.tsx:467 +msgid "Confirm transfer" +msgstr "Confirmar transferencia" + +#: src/routes/party_.$partyId.transfer-debt.tsx:460 +msgid "Confirming..." +msgstr "Confirmando..." + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Franco Congoleño" @@ -489,6 +516,10 @@ msgstr "Colón Costarricense" msgid "Create a new Party" msgstr "Crear un nuevo Grupo" +#: src/routes/party_.$partyId.transfer-debt.tsx:487 +msgid "Creditor" +msgstr "Acreedor" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Créditos" @@ -536,6 +567,10 @@ msgstr "Corona Danesa" msgid "Date" msgstr "Fecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Debt being moved" +msgstr "Deuda que se va a mover" + #: src/routes/party_.$partyId.pay.tsx:70 msgid "Debt settled between {0} and {1}!" msgstr "¡Deuda saldada entre {0} y {1}!" @@ -544,6 +579,19 @@ msgstr "¡Deuda saldada entre {0} y {1}!" msgid "Debt settled between {fromName} and {toName}!" msgstr "¡Deuda saldada entre {fromName} y {toName}!" +#: src/routes/party_.$partyId.transfer-debt.tsx:386 +msgid "Debt transfer from another party" +msgstr "Transferencia de deuda desde otro grupo" + +#: src/routes/party_.$partyId.transfer-debt.tsx:385 +msgid "Debt transfer to another party" +msgstr "Transferencia de deuda a otro grupo" + +#: src/routes/party_.$partyId.transfer-debt.tsx:343 +#: src/routes/party_.$partyId.transfer-debt.tsx:961 +msgid "Debt transferred" +msgstr "Deuda transferida" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Punto decimal" @@ -561,6 +609,10 @@ msgstr "Descripción" msgid "Description must be less than 500 characters" msgstr "La descripción debe tener menos de 500 caracteres" +#: src/routes/party_.$partyId.transfer-debt.tsx:538 +msgid "Destination party" +msgstr "Grupo de destino" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "¿Sabías que?" @@ -671,7 +723,7 @@ msgstr "Las cantidades de los gastos no coinciden con el total. Por favor, revis msgid "Expense updated" msgstr "Gasto actualizado" -#: src/routes/party.$partyId.tsx:357 +#: src/routes/party.$partyId.tsx:358 msgid "Expenses" msgstr "Gastos" @@ -707,6 +759,10 @@ msgstr "Error al cargar las licencias. Por favor, inténtalo más tarde." msgid "Failed to mark expense as paid" msgstr "Error al marcar el gasto como pagado" +#: src/routes/party_.$partyId.transfer-debt.tsx:393 +msgid "Failed to transfer debt" +msgstr "No se pudo transferir la deuda" + #: src/routes/party_.$partyId.expense.$expenseId_.edit.tsx:171 msgid "Failed to update expense" msgstr "Error al actualizar el gasto" @@ -776,9 +832,14 @@ msgid "Go back" msgstr "Volver" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:589 msgid "Go Back" msgstr "Volver" +#: src/routes/party_.$partyId.transfer-debt.tsx:311 +msgid "Go back and try again from the balances list." +msgstr "Vuelve atrás e inténtalo de nuevo desde la lista de saldos." + #: src/routes/migrate_.tricount.tsx:315 msgid "Go back to home" msgstr "Volver al inicio" @@ -811,7 +872,7 @@ msgstr "¿Tienes una idea para mejorar trizum? Nos encantaría escucharla." msgid "Heads up!" msgstr "¡Atención!" -#: src/routes/party.$partyId.tsx:772 +#: src/routes/party.$partyId.tsx:775 msgid "Here is a list of operations you and other party members can do to balance your position." msgstr "Aquí tienes una lista de operaciones que tú y otros miembros del grupo podéis hacer para equilibrar vuestra posición." @@ -840,7 +901,7 @@ msgstr "¿Cómo quieres llamar a este grupo?" msgid "How does offline sync work?" msgstr "¿Cómo funciona la sincronización sin conexión?" -#: src/routes/party.$partyId.tsx:768 +#: src/routes/party.$partyId.tsx:771 msgid "How should I balance?" msgstr "¿Cómo debería equilibrar?" @@ -868,7 +929,7 @@ msgstr "La imagen debe ser menor de 10MB" msgid "Impact on my balance: <0/>" msgstr "Impacto en mi balance: <0/>" -#: src/routes/party.$partyId.tsx:660 +#: src/routes/party.$partyId.tsx:661 msgid "Impact on my balance: <0/>" msgstr "Impacto en mi balance: <0/>" @@ -1011,7 +1072,7 @@ msgstr "Último uso" msgid "Last year" msgstr "Año pasado" -#: src/routes/party.$partyId.tsx:341 +#: src/routes/party.$partyId.tsx:342 msgid "Leave party" msgstr "Dejar el grupo" @@ -1085,7 +1146,7 @@ msgstr "Gestiona la lista de participantes. Los miembros existentes solo pueden #: src/routes/party_.$partyId.pay.tsx:93 #: src/routes/party_.$partyId.pay.tsx:127 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Mark as paid" msgstr "Marcar como pagado" @@ -1111,7 +1172,7 @@ msgstr "Yo" #: src/components/ExpenseEditor.tsx:268 #: src/routes/index.tsx:117 #: src/routes/party_.$partyId.expense.$expenseId.tsx:103 -#: src/routes/party.$partyId.tsx:225 +#: src/routes/party.$partyId.tsx:226 msgid "Menu" msgstr "Menú" @@ -1138,6 +1199,7 @@ msgid "Migration successful" msgstr "Migración exitosa" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:209 msgid "Missing search params" msgstr "Faltan parámetros de búsqueda" @@ -1161,6 +1223,10 @@ msgstr "Mover cursor a la izquierda" msgid "Move cursor right" msgstr "Mover cursor a la derecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:853 +msgid "Moved to" +msgstr "Movida a" + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Metical Mozambiqueño" @@ -1179,7 +1245,7 @@ msgstr "Kyat Birmano" #: src/routes/new.tsx:186 #: src/routes/party_.$partyId.settings.tsx:166 -#: src/routes/party.$partyId.tsx:175 +#: src/routes/party.$partyId.tsx:176 msgid "Name" msgstr "Nombre" @@ -1252,6 +1318,10 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" +#: src/routes/party_.$partyId.transfer-debt.tsx:532 +msgid "No destination party available" +msgstr "No hay ningún grupo de destino disponible" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No se encontraron emojis" @@ -1269,7 +1339,11 @@ msgstr "No hay estadísticas de gastos para este período" msgid "No spending stats yet" msgstr "Aún no hay estadísticas de gastos" -#: src/routes/party.$partyId.tsx:854 +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Nobody else is available in this party" +msgstr "No hay nadie más disponible en este grupo" + +#: src/routes/party.$partyId.tsx:858 msgid "Nobody owes you money!" msgstr "¡Nadie te debe dinero!" @@ -1309,10 +1383,14 @@ msgstr "Los grupos antiguos quedan apartados, no desaparecen" msgid "Omani Rial" msgstr "Rial Omaní" -#: src/routes/party.$partyId.tsx:266 +#: src/routes/party.$partyId.tsx:267 msgid "Only show your expenses" msgstr "Mostrar solo tus gastos" +#: src/routes/party_.$partyId.transfer-debt.tsx:321 +msgid "Only your own debt can be transferred" +msgstr "Solo puedes transferir tus propias deudas" + #: src/routes/index.tsx:377 msgid "Open archived parties" msgstr "Abrir grupos archivados" @@ -1337,16 +1415,20 @@ msgstr "Abrir el último grupo al abrir la app" msgid "Open parenthesis" msgstr "Abrir paréntesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:976 +msgid "Opening updated expense…" +msgstr "Abriendo el gasto actualizado…" + #: src/routes/join.tsx:157 msgid "or enter code" msgstr "o introduce código" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party.$partyId.tsx:874 msgid "Other operations" msgstr "Otras operaciones" #: src/routes/party_.$partyId.pay.tsx:104 -#: src/routes/party.$partyId.tsx:976 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1464,15 +1546,15 @@ msgid "Paste the Tricount sharing message, URL (e.g., https://tricount.com/abc12 msgstr "Pega el mensaje de compartir de Tricount, la URL (p. ej., https://tricount.com/abc123), o la clave directa" #: src/routes/party_.$partyId.pay.tsx:93 -#: src/routes/party.$partyId.tsx:1010 +#: src/routes/party.$partyId.tsx:1020 msgid "Pay" msgstr "Pagar" -#: src/routes/party.$partyId.tsx:831 +#: src/routes/party.$partyId.tsx:835 msgid "People that owe you money" msgstr "Personas que te deben dinero" -#: src/routes/party.$partyId.tsx:262 +#: src/routes/party.$partyId.tsx:263 msgid "Personal mode" msgstr "Modo personal" @@ -1572,6 +1654,10 @@ msgstr "Clasificación según cuánto ha pagado cada participante en el período msgid "Real-Time Sync" msgstr "Sincronización en Tiempo Real" +#: src/routes/party_.$partyId.transfer-debt.tsx:776 +msgid "Recommended match" +msgstr "Coincidencia recomendada" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Recargar" @@ -1663,7 +1749,7 @@ msgstr "Buscar emoji" msgid "Search emoji..." msgstr "Buscar emoji..." -#: src/routes/party.$partyId.tsx:294 +#: src/routes/party.$partyId.tsx:295 msgid "See spending totals and rankings" msgstr "Ver totales y clasificación de gasto" @@ -1684,7 +1770,7 @@ msgid "Set up your username, avatar, and preferences" msgstr "Configura tu nombre de usuario, avatar y preferencias" #: src/routes/index.tsx:133 -#: src/routes/party.$partyId.tsx:329 +#: src/routes/party.$partyId.tsx:330 #: src/routes/settings.tsx:91 msgid "Settings" msgstr "Configuración" @@ -1693,13 +1779,17 @@ msgstr "Configuración" msgid "Settings saved" msgstr "Configuración guardada" +#: src/routes/party_.$partyId.transfer-debt.tsx:836 +msgid "Settled in" +msgstr "Saldada en" + #: src/routes/new.tsx:554 msgid "Seychellois Rupee" msgstr "Rupia de Seychelles" #: src/routes/party_.$partyId.share.tsx:58 #: src/routes/party_.$partyId.share.tsx:93 -#: src/routes/party.$partyId.tsx:312 +#: src/routes/party.$partyId.tsx:313 msgid "Share party" msgstr "Compartir grupo" @@ -1751,7 +1841,7 @@ msgstr "Chelín Somalí" msgid "Something went wrong" msgstr "Algo salió mal" -#: src/routes/party.$partyId.tsx:162 +#: src/routes/party.$partyId.tsx:163 msgid "Sort balances" msgstr "Ordenar balances" @@ -1796,7 +1886,7 @@ msgstr "Fecha de inicio" msgid "Start tracking expenses with your group" msgstr "Comienza a registrar gastos con tu grupo" -#: src/routes/party.$partyId.tsx:290 +#: src/routes/party.$partyId.tsx:291 msgid "Stats" msgstr "Estadísticas" @@ -1896,7 +1986,7 @@ msgstr "Hacer foto" msgid "Tanzanian Shilling" msgstr "Chelín Tanzano" -#: src/routes/party.$partyId.tsx:248 +#: src/routes/party.$partyId.tsx:249 msgid "Tap to change" msgstr "Toca para cambiar" @@ -1912,6 +2002,10 @@ msgstr "Gracias por usar trizum. ¡Tus comentarios nos ayudan a mejorar!" msgid "The app works offline too!" msgstr "¡La app también funciona sin conexión!" +#: src/routes/party_.$partyId.transfer-debt.tsx:965 +msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." +msgstr "La nueva deuda está ahora en <0>{destinationPartyName}, donde <2>{destinationDebtorName} debe dinero a <1>{destinationCounterpartyName}." + #: src/components/PartyPendingComponent.tsx:27 msgid "The party data hasn't synced to the sync server yet." msgstr "Los datos del grupo aún no se han sincronizado con el servidor." @@ -1934,6 +2028,10 @@ msgstr "Licencias de Terceros" msgid "This application uses the following open source libraries and tools. Below are their licenses and attributions." msgstr "Esta aplicación utiliza las siguientes bibliotecas y herramientas de código abierto. A continuación se muestran sus licencias y atribuciones." +#: src/routes/party_.$partyId.transfer-debt.tsx:310 +msgid "This debt is no longer available" +msgstr "Esta deuda ya no está disponible" + #: src/hooks/useMediaFileActions.ts:34 msgid "This HEIC or HEIF image could not be processed. Try another photo or export it as JPEG or PNG." msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o expórtala como JPEG o PNG." @@ -1942,6 +2040,10 @@ msgstr "Esta imagen HEIC o HEIF no se pudo procesar. Prueba con otra foto o exp msgid "This isn't a valid ID" msgstr "Este no es un ID válido" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "This party needs another active participant besides you to receive the transferred debt." +msgstr "Este grupo necesita otro participante activo además de ti para recibir la deuda transferida." + #: src/routes/about.tsx:144 msgid "This project uses various open source libraries and tools." msgstr "Este proyecto utiliza varias bibliotecas y herramientas de código abierto." @@ -1987,6 +2089,16 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" +#: src/routes/party_.$partyId.transfer-debt.tsx:308 +#: src/routes/party_.$partyId.transfer-debt.tsx:319 +#: src/routes/party_.$partyId.transfer-debt.tsx:344 +msgid "Transfer debt" +msgstr "Transferir deuda" + +#: src/routes/party.$partyId.tsx:1047 +msgid "Transfer to another party" +msgstr "Transferir a otro grupo" + #: src/routes/migrate_.tricount.tsx:57 msgid "Tricount key or URL is required" msgstr "Se requiere la clave o URL de Tricount" @@ -2151,7 +2263,7 @@ msgstr "Ver Licencias de Terceros" msgid "Viewing as {0}" msgstr "Viendo como {0}" -#: src/routes/party.$partyId.tsx:244 +#: src/routes/party.$partyId.tsx:245 msgid "Viewing as {participantName}" msgstr "Viendo como {participantName}" @@ -2212,15 +2324,23 @@ msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sin msgid "You can add photos to your expenses for better tracking." msgstr "Puedes añadir fotos a tus gastos para un mejor seguimiento." -#: src/routes/party.$partyId.tsx:127 +#: src/routes/party_.$partyId.transfer-debt.tsx:322 +msgid "You can only transfer debt from actions where you are the one who owes the money." +msgstr "Solo puedes transferir deudas de acciones en las que tú eres quien debe el dinero." + +#: src/routes/party.$partyId.tsx:128 msgid "You left the party!" msgstr "¡Has dejado el grupo!" -#: src/routes/party.$partyId.tsx:792 +#: src/routes/party_.$partyId.transfer-debt.tsx:533 +msgid "You need another active party with the same currency to transfer this debt." +msgstr "Necesitas otro grupo activo con la misma moneda para transferir esta deuda." + +#: src/routes/party.$partyId.tsx:795 msgid "You owe money to people" msgstr "Debes dinero a personas" -#: src/routes/party.$partyId.tsx:815 +#: src/routes/party.$partyId.tsx:819 msgid "You're debt free!" msgstr "¡Estás libre de deudas!" diff --git a/packages/pwa/src/hooks/useEligibleDebtTransferParties.test.ts b/packages/pwa/src/hooks/useEligibleDebtTransferParties.test.ts new file mode 100644 index 00000000..819291b2 --- /dev/null +++ b/packages/pwa/src/hooks/useEligibleDebtTransferParties.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import type { Party } from "#src/models/party.ts"; +import { getEligibleDebtTransferParticipants } from "./useEligibleDebtTransferParties"; + +describe("getEligibleDebtTransferParticipants", () => { + test("returns active counterparties sorted by name", () => { + const result = getEligibleDebtTransferParticipants( + createParty({ + me: { id: "me", name: "Me" }, + zoe: { id: "zoe", name: "Zoe" }, + alex: { id: "alex", name: "Alex" }, + archived: { id: "archived", name: "Archived", isArchived: true }, + }), + "me", + ); + + expect(result?.currentParticipant.id).toBe("me"); + expect(result?.otherParticipants.map(({ id }) => id)).toEqual([ + "alex", + "zoe", + ]); + }); + + test("rejects parties where the joined participant is archived", () => { + expect( + getEligibleDebtTransferParticipants( + createParty({ + me: { id: "me", name: "Me", isArchived: true }, + alex: { id: "alex", name: "Alex" }, + }), + "me", + ), + ).toBeNull(); + }); + + test("rejects parties without another active participant", () => { + expect( + getEligibleDebtTransferParticipants( + createParty({ + me: { id: "me", name: "Me" }, + archived: { id: "archived", name: "Archived", isArchived: true }, + }), + "me", + ), + ).toBeNull(); + }); +}); + +function createParty(participants: Party["participants"]): Party { + return { + id: "party-id" as Party["id"], + type: "party", + name: "Test party", + description: "", + currency: "EUR", + participants, + chunkRefs: [], + }; +} diff --git a/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts new file mode 100644 index 00000000..cafa3237 --- /dev/null +++ b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts @@ -0,0 +1,100 @@ +import type { Party, PartyParticipant } from "#src/models/party.ts"; +import { getOrderedPartySections } from "#src/lib/partyListOrdering.ts"; +import { useMultipleSuspenseDocument } from "#src/lib/automerge/suspense-hooks.ts"; +import { useCurrentParty } from "./useParty"; +import { usePartyList } from "./usePartyList"; + +export interface EligibleDebtTransferParty { + party: Party; + currentParticipantId: PartyParticipant["id"]; + currentParticipant: PartyParticipant; + otherParticipants: PartyParticipant[]; +} + +interface EligibleDebtTransferParticipants { + currentParticipant: PartyParticipant; + otherParticipants: PartyParticipant[]; +} + +export function getEligibleDebtTransferParticipants( + party: Party, + currentParticipantId: PartyParticipant["id"], +): EligibleDebtTransferParticipants | null { + const currentParticipant = party.participants[currentParticipantId]; + + if (!currentParticipant || currentParticipant.isArchived) { + return null; + } + + const otherParticipants = Object.values(party.participants) + .filter( + (participant) => + !participant.isArchived && participant.id !== currentParticipant.id, + ) + .sort((left, right) => left.name.localeCompare(right.name)); + + if (otherParticipants.length === 0) { + return null; + } + + return { + currentParticipant, + otherParticipants, + }; +} + +export function useEligibleDebtTransferParties(): EligibleDebtTransferParty[] { + const { party: originParty } = useCurrentParty(); + const { partyList } = usePartyList(); + const { activePartyIds } = getOrderedPartySections(partyList); + + const joinedActiveParties = activePartyIds.flatMap((partyId) => { + const currentParticipantId = partyList.participantInParties[partyId]; + + if (partyId === originParty.id || currentParticipantId === undefined) { + return []; + } + + return [ + { + partyId, + currentParticipantId, + }, + ]; + }); + + const partyEntries = useMultipleSuspenseDocument( + joinedActiveParties.map(({ partyId }) => partyId), + ); + + return partyEntries.flatMap(({ doc }, index) => { + if (!doc || doc.currency !== originParty.currency) { + return []; + } + + const joinedActiveParty = joinedActiveParties[index]; + + if (!joinedActiveParty) { + return []; + } + + const currentParticipantId = joinedActiveParty.currentParticipantId; + const eligibleParticipants = getEligibleDebtTransferParticipants( + doc, + currentParticipantId, + ); + + if (!eligibleParticipants) { + return []; + } + + return [ + { + party: doc, + currentParticipantId, + currentParticipant: eligibleParticipants.currentParticipant, + otherParticipants: eligibleParticipants.otherParticipants, + }, + ]; + }); +} diff --git a/packages/pwa/src/hooks/useParty.ts b/packages/pwa/src/hooks/useParty.ts index cec606f4..d1271dd6 100644 --- a/packages/pwa/src/hooks/useParty.ts +++ b/packages/pwa/src/hooks/useParty.ts @@ -30,6 +30,7 @@ import { import { clone } from "@opentf/std"; import { useParams } from "@tanstack/react-router"; import { getLogger } from "#src/lib/log.ts"; +import { createDebtTransferExpenses } from "#src/lib/debtTransfer.ts"; const logger = getLogger("hooks", "useParty"); @@ -395,6 +396,103 @@ export function getPartyHelpers(repo: Repo, handle: DocHandle) { return true; } + async function transferDebtToParty({ + destinationPartyId, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + amount, + paidAt, + originExpenseName, + destinationExpenseName, + }: { + destinationPartyId: Party["id"]; + originDebtorId: PartyParticipant["id"]; + originCreditorId: PartyParticipant["id"]; + destinationDebtorId: PartyParticipant["id"]; + destinationCreditorId: PartyParticipant["id"]; + amount: number; + paidAt: Date; + originExpenseName: string; + destinationExpenseName: string; + }) { + const originParty = handle.doc(); + + if (!originParty) { + throw new Error("Party not found, this should not happen"); + } + + if (destinationPartyId === originParty.id) { + throw new Error("Cannot transfer debt to the same party"); + } + + if (!originParty.participants[originDebtorId]) { + throw new Error("Origin debtor not found"); + } + + if (!originParty.participants[originCreditorId]) { + throw new Error("Origin creditor not found"); + } + + const destinationHandle = await repo.find(destinationPartyId); + const destinationParty = destinationHandle.doc(); + + if (!destinationParty) { + throw new Error("Destination party not found"); + } + + if (destinationParty.currency !== originParty.currency) { + throw new Error( + "Cannot transfer debt between parties with different currencies", + ); + } + + if (!destinationParty.participants[destinationDebtorId]) { + throw new Error("Destination debtor not found"); + } + + if (!destinationParty.participants[destinationCreditorId]) { + throw new Error("Destination creditor not found"); + } + + const destinationHelpers = getPartyHelpers(repo, destinationHandle); + const { originExpense, destinationExpense } = createDebtTransferExpenses({ + amount, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + paidAt, + originExpenseName, + destinationExpenseName, + }); + + const createdDestinationExpense = + await destinationHelpers.addExpenseToParty(destinationExpense); + + try { + const createdOriginExpense = await addExpenseToParty(originExpense); + + return { + originExpense: createdOriginExpense, + destinationExpense: createdDestinationExpense, + }; + } catch (error) { + try { + await destinationHelpers.removeExpense(createdDestinationExpense.id); + } catch (rollbackError) { + logger.error("Failed to rollback destination debt transfer expense", { + rollbackError, + destinationExpenseId: createdDestinationExpense.id, + destinationPartyId, + }); + } + + throw error; + } + } + async function recalculateBalances() { const party = handle.doc(); @@ -442,6 +540,7 @@ export function getPartyHelpers(repo: Repo, handle: DocHandle) { updateSettings, setParticipantDetails, addExpenseToParty, + transferDebtToParty, updateExpense, removeExpense, recalculateBalances, diff --git a/packages/pwa/src/lib/automerge/suspense-hooks.ts b/packages/pwa/src/lib/automerge/suspense-hooks.ts index 9474f7af..afd3d9a9 100644 --- a/packages/pwa/src/lib/automerge/suspense-hooks.ts +++ b/packages/pwa/src/lib/automerge/suspense-hooks.ts @@ -387,11 +387,11 @@ export function useMultipleSuspenseDocument< }, ); - if ((options?.required && !docs) || !docs?.every((doc) => doc)) { + if (options?.required && (!docs || !docs.every((doc) => doc))) { throw new Error(`Document not found: ${ids.join(", ")}`); } - return docs.map((doc, index) => ({ + return (docs ?? []).map((doc, index) => ({ doc: doc as Doc | undefined, handle: handleCache.read(repo, ids[index]) as DocHandle, })); diff --git a/packages/pwa/src/lib/debtTransfer.test.ts b/packages/pwa/src/lib/debtTransfer.test.ts new file mode 100644 index 00000000..625a4ec8 --- /dev/null +++ b/packages/pwa/src/lib/debtTransfer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "vitest"; +import { + createDebtTransferExpenses, + getDebtTransferParticipantMatch, +} from "./debtTransfer"; + +describe("createDebtTransferExpenses", () => { + test("creates a settlement expense in the origin party and a new debt in the destination party", () => { + const paidAt = new Date("2026-04-18T10:20:30.000Z"); + + const { originExpense, destinationExpense } = createDebtTransferExpenses({ + amount: 4_000, + originDebtorId: "me-origin", + originCreditorId: "juan-origin", + destinationDebtorId: "me-destination", + destinationCreditorId: "juan-destination", + paidAt, + originExpenseName: "Debt transfer to another party", + destinationExpenseName: "Debt transfer from another party", + }); + + expect(originExpense).toEqual({ + name: "Debt transfer to another party", + paidAt, + paidBy: { + "me-origin": 4_000, + }, + shares: { + "juan-origin": { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }); + + expect(destinationExpense).toEqual({ + name: "Debt transfer from another party", + paidAt, + paidBy: { + "juan-destination": 4_000, + }, + shares: { + "me-destination": { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }); + }); + + test("rejects non-positive transfer amounts", () => { + expect(() => + createDebtTransferExpenses({ + amount: 0, + originDebtorId: "me-origin", + originCreditorId: "juan-origin", + destinationDebtorId: "me-destination", + destinationCreditorId: "juan-destination", + paidAt: new Date("2026-04-18T10:20:30.000Z"), + originExpenseName: "Debt transfer to another party", + destinationExpenseName: "Debt transfer from another party", + }), + ).toThrow("Debt transfer amount must be greater than 0"); + }); +}); + +describe("getDebtTransferParticipantMatch", () => { + test("preselects a single full-name match", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan Smith", + participants: [ + { id: "juan-1", name: "Juan Smith" }, + { id: "juan-2", name: "Juan S." }, + ], + }), + ).toEqual({ + exactMatchParticipantId: "juan-1", + recommendedParticipantIds: [], + }); + }); + + test("returns quick recommendations when the name only partially matches", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan", + participants: [ + { id: "juan-smith", name: "Juan Smith" }, + { id: "dani", name: "Dani" }, + { id: "juan-perez", name: "Juan Perez" }, + ], + }), + ).toEqual({ + exactMatchParticipantId: null, + recommendedParticipantIds: ["juan-perez", "juan-smith"], + }); + }); + + test("does not auto-pick when multiple full-name matches exist", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: "Juan", + participants: [ + { id: "juan-1", name: "Juan" }, + { id: "juan-2", name: "Juan" }, + { id: "dani", name: "Dani" }, + ], + }), + ).toEqual({ + exactMatchParticipantId: null, + recommendedParticipantIds: ["juan-1", "juan-2"], + }); + }); + + test("normalizes accents, punctuation, and spacing when matching", () => { + expect( + getDebtTransferParticipantMatch({ + sourceName: " Juán-Smith ", + participants: [{ id: "juan-smith", name: "Juan Smith" }], + }), + ).toEqual({ + exactMatchParticipantId: "juan-smith", + recommendedParticipantIds: [], + }); + }); +}); diff --git a/packages/pwa/src/lib/debtTransfer.ts b/packages/pwa/src/lib/debtTransfer.ts new file mode 100644 index 00000000..bab9efd4 --- /dev/null +++ b/packages/pwa/src/lib/debtTransfer.ts @@ -0,0 +1,176 @@ +import type { Expense } from "#src/models/expense.ts"; +import type { PartyParticipant } from "#src/models/party.ts"; + +interface CreateDebtTransferExpensesOptions { + amount: number; + originDebtorId: string; + originCreditorId: string; + destinationDebtorId: string; + destinationCreditorId: string; + paidAt: Date; + originExpenseName: string; + destinationExpenseName: string; +} + +interface GetDebtTransferParticipantMatchOptions { + sourceName: string; + participants: Array>; + recommendationLimit?: number; +} + +export interface DebtTransferParticipantMatch { + exactMatchParticipantId: PartyParticipant["id"] | null; + recommendedParticipantIds: PartyParticipant["id"][]; +} + +export function createDebtTransferExpenses({ + amount, + originDebtorId, + originCreditorId, + destinationDebtorId, + destinationCreditorId, + paidAt, + originExpenseName, + destinationExpenseName, +}: CreateDebtTransferExpensesOptions): { + originExpense: Omit; + destinationExpense: Omit; +} { + if (amount <= 0) { + throw new Error("Debt transfer amount must be greater than 0"); + } + + return { + originExpense: { + name: originExpenseName, + paidAt: new Date(paidAt), + paidBy: { + [originDebtorId]: amount, + }, + shares: { + [originCreditorId]: { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }, + destinationExpense: { + name: destinationExpenseName, + paidAt: new Date(paidAt), + paidBy: { + [destinationCreditorId]: amount, + }, + shares: { + [destinationDebtorId]: { + type: "divide", + value: 1, + }, + }, + photos: [], + isTransfer: true, + }, + }; +} + +export function getDebtTransferParticipantMatch({ + sourceName, + participants, + recommendationLimit = 3, +}: GetDebtTransferParticipantMatchOptions): DebtTransferParticipantMatch { + const normalizedSourceName = normalizeParticipantName(sourceName); + + if (!normalizedSourceName) { + return { + exactMatchParticipantId: null, + recommendedParticipantIds: [], + }; + } + + const normalizedSourceTokens = tokenizeParticipantName(normalizedSourceName); + const participantsWithMatchScore = participants.map((participant) => { + const normalizedParticipantName = normalizeParticipantName( + participant.name, + ); + + return { + participant, + normalizedParticipantName, + matchScore: getParticipantMatchScore({ + normalizedSourceName, + normalizedSourceTokens, + normalizedParticipantName, + }), + }; + }); + + const exactMatches = participantsWithMatchScore.filter( + ({ normalizedParticipantName }) => + normalizedParticipantName === normalizedSourceName, + ); + + if (exactMatches.length === 1) { + return { + exactMatchParticipantId: exactMatches[0].participant.id, + recommendedParticipantIds: [], + }; + } + + return { + exactMatchParticipantId: null, + recommendedParticipantIds: participantsWithMatchScore + .filter(({ matchScore }) => matchScore > 0) + .sort((left, right) => { + if (left.matchScore !== right.matchScore) { + return right.matchScore - left.matchScore; + } + + return left.participant.name.localeCompare(right.participant.name); + }) + .slice(0, recommendationLimit) + .map(({ participant }) => participant.id), + }; +} + +function getParticipantMatchScore({ + normalizedSourceName, + normalizedSourceTokens, + normalizedParticipantName, +}: { + normalizedSourceName: string; + normalizedSourceTokens: string[]; + normalizedParticipantName: string; +}) { + if (!normalizedParticipantName) { + return 0; + } + + if (normalizedParticipantName === normalizedSourceName) { + return 100; + } + + const participantTokens = tokenizeParticipantName(normalizedParticipantName); + const sharedTokens = normalizedSourceTokens.filter((token) => + participantTokens.includes(token), + ).length; + const hasContainedName = + normalizedParticipantName.includes(normalizedSourceName) || + normalizedSourceName.includes(normalizedParticipantName); + + return sharedTokens * 10 + (hasContainedName ? 5 : 0); +} + +function tokenizeParticipantName(name: string) { + return name.split(" ").filter(Boolean); +} + +function normalizeParticipantName(name: string) { + return name + .trim() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/['’.-]+/g, " ") + .replace(/\s+/g, " "); +} diff --git a/packages/pwa/src/routeTree.gen.ts b/packages/pwa/src/routeTree.gen.ts index be0c82dd..2a906924 100644 --- a/packages/pwa/src/routeTree.gen.ts +++ b/packages/pwa/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as PartyPartyIdRouteImport } from './routes/party.$partyId' import { Route as MigrateTricountRouteImport } from './routes/migrate_.tricount' import { Route as AboutThirdPartyLicensesRouteImport } from './routes/about_.third-party-licenses' import { Route as PartyPartyIdWhoRouteImport } from './routes/party_.$partyId.who' +import { Route as PartyPartyIdTransferDebtRouteImport } from './routes/party_.$partyId.transfer-debt' import { Route as PartyPartyIdStatsRouteImport } from './routes/party_.$partyId.stats' import { Route as PartyPartyIdShareRouteImport } from './routes/party_.$partyId.share' import { Route as PartyPartyIdSettingsRouteImport } from './routes/party_.$partyId.settings' @@ -89,6 +90,12 @@ const PartyPartyIdWhoRoute = PartyPartyIdWhoRouteImport.update({ path: '/party/$partyId/who', getParentRoute: () => rootRouteImport, } as any) +const PartyPartyIdTransferDebtRoute = + PartyPartyIdTransferDebtRouteImport.update({ + id: '/party_/$partyId/transfer-debt', + path: '/party/$partyId/transfer-debt', + getParentRoute: () => rootRouteImport, + } as any) const PartyPartyIdStatsRoute = PartyPartyIdStatsRouteImport.update({ id: '/party_/$partyId/stats', path: '/party/$partyId/stats', @@ -144,6 +151,7 @@ export interface FileRoutesByFullPath { '/party/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party/$partyId/share': typeof PartyPartyIdShareRoute '/party/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party/$partyId/who': typeof PartyPartyIdWhoRoute '/party/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party/$partyId/expense/$expenseId/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -165,6 +173,7 @@ export interface FileRoutesByTo { '/party/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party/$partyId/share': typeof PartyPartyIdShareRoute '/party/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party/$partyId/who': typeof PartyPartyIdWhoRoute '/party/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party/$partyId/expense/$expenseId/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -187,6 +196,7 @@ export interface FileRoutesById { '/party_/$partyId/settings': typeof PartyPartyIdSettingsRoute '/party_/$partyId/share': typeof PartyPartyIdShareRoute '/party_/$partyId/stats': typeof PartyPartyIdStatsRoute + '/party_/$partyId/transfer-debt': typeof PartyPartyIdTransferDebtRoute '/party_/$partyId/who': typeof PartyPartyIdWhoRoute '/party_/$partyId/expense/$expenseId': typeof PartyPartyIdExpenseExpenseIdRoute '/party_/$partyId/expense/$expenseId_/edit': typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -210,6 +220,7 @@ export interface FileRouteTypes { | '/party/$partyId/settings' | '/party/$partyId/share' | '/party/$partyId/stats' + | '/party/$partyId/transfer-debt' | '/party/$partyId/who' | '/party/$partyId/expense/$expenseId' | '/party/$partyId/expense/$expenseId/edit' @@ -231,6 +242,7 @@ export interface FileRouteTypes { | '/party/$partyId/settings' | '/party/$partyId/share' | '/party/$partyId/stats' + | '/party/$partyId/transfer-debt' | '/party/$partyId/who' | '/party/$partyId/expense/$expenseId' | '/party/$partyId/expense/$expenseId/edit' @@ -252,6 +264,7 @@ export interface FileRouteTypes { | '/party_/$partyId/settings' | '/party_/$partyId/share' | '/party_/$partyId/stats' + | '/party_/$partyId/transfer-debt' | '/party_/$partyId/who' | '/party_/$partyId/expense/$expenseId' | '/party_/$partyId/expense/$expenseId_/edit' @@ -274,6 +287,7 @@ export interface RootRouteChildren { PartyPartyIdSettingsRoute: typeof PartyPartyIdSettingsRoute PartyPartyIdShareRoute: typeof PartyPartyIdShareRoute PartyPartyIdStatsRoute: typeof PartyPartyIdStatsRoute + PartyPartyIdTransferDebtRoute: typeof PartyPartyIdTransferDebtRoute PartyPartyIdWhoRoute: typeof PartyPartyIdWhoRoute PartyPartyIdExpenseExpenseIdRoute: typeof PartyPartyIdExpenseExpenseIdRoute PartyPartyIdExpenseExpenseIdEditRoute: typeof PartyPartyIdExpenseExpenseIdEditRoute @@ -365,6 +379,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PartyPartyIdWhoRouteImport parentRoute: typeof rootRouteImport } + '/party_/$partyId/transfer-debt': { + id: '/party_/$partyId/transfer-debt' + path: '/party/$partyId/transfer-debt' + fullPath: '/party/$partyId/transfer-debt' + preLoaderRoute: typeof PartyPartyIdTransferDebtRouteImport + parentRoute: typeof rootRouteImport + } '/party_/$partyId/stats': { id: '/party_/$partyId/stats' path: '/party/$partyId/stats' @@ -434,6 +455,7 @@ const rootRouteChildren: RootRouteChildren = { PartyPartyIdSettingsRoute: PartyPartyIdSettingsRoute, PartyPartyIdShareRoute: PartyPartyIdShareRoute, PartyPartyIdStatsRoute: PartyPartyIdStatsRoute, + PartyPartyIdTransferDebtRoute: PartyPartyIdTransferDebtRoute, PartyPartyIdWhoRoute: PartyPartyIdWhoRoute, PartyPartyIdExpenseExpenseIdRoute: PartyPartyIdExpenseExpenseIdRoute, PartyPartyIdExpenseExpenseIdEditRoute: PartyPartyIdExpenseExpenseIdEditRoute, diff --git a/packages/pwa/src/routes/party.$partyId.tsx b/packages/pwa/src/routes/party.$partyId.tsx index 4d0b72be..2fb03c86 100644 --- a/packages/pwa/src/routes/party.$partyId.tsx +++ b/packages/pwa/src/routes/party.$partyId.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { usePartyPaginatedExpenses } from "#src/hooks/usePartyPaginatedExpenses.js"; import { useCurrentParty, useParty } from "#src/hooks/useParty.js"; import { useCurrentParticipant } from "#src/hooks/useCurrentParticipant.js"; +import { useEligibleDebtTransferParties } from "#src/hooks/useEligibleDebtTransferParties.ts"; import { CurrencyText } from "#src/components/CurrencyText.js"; import { guardParticipatingInParty } from "#src/lib/guards.js"; import { AnimatedTabs } from "#src/ui/AnimatedTabs.js"; @@ -728,6 +729,8 @@ function Balances({ const isFullyBalanced = userOwesMap.length === 0 && owedToUserMap.length === 0; + const eligibleTransferParties = useEligibleDebtTransferParties(); + const canTransferDebt = eligibleTransferParties.length > 0; // Show other transactions not involving the current user const allOtherDiffs = simplifiedTransactions @@ -799,6 +802,7 @@ function Balances({ fromId={participant.id} toId={participantId} amount={amount} + canTransferDebt={canTransferDebt} /> ))} @@ -955,9 +959,15 @@ interface BalanceActionItemProps { fromId: PartyParticipant["id"]; toId: PartyParticipant["id"]; amount: number; + canTransferDebt?: boolean; } -function BalanceActionItem({ fromId, toId, amount }: BalanceActionItemProps) { +function BalanceActionItem({ + fromId, + toId, + amount, + canTransferDebt = false, +}: BalanceActionItemProps) { const { party } = useCurrentParty(); const me = useCurrentParticipant(); const from = party.participants[fromId]; @@ -989,10 +999,10 @@ function BalanceActionItem({ fromId, toId, amount }: BalanceActionItemProps) { -
+
+ + {isFromMe && canTransferDebt ? ( + + ) : null}
); diff --git a/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx new file mode 100644 index 00000000..85809606 --- /dev/null +++ b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx @@ -0,0 +1,1057 @@ +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import type { Currency } from "dinero.js"; +import { AnimatePresence, motion } from "motion/react"; +import { Suspense, useMemo, useReducer } from "react"; +import { toast } from "sonner"; +import { BackButton } from "#src/components/BackButton.tsx"; +import { CurrencyText } from "#src/components/CurrencyText.tsx"; +import { PartyPendingComponent } from "#src/components/PartyPendingComponent.tsx"; +import { useCurrentParticipant } from "#src/hooks/useCurrentParticipant.ts"; +import { + type EligibleDebtTransferParty, + useEligibleDebtTransferParties, +} from "#src/hooks/useEligibleDebtTransferParties.ts"; +import { useMediaFile } from "#src/hooks/useMediaFile.ts"; +import { useCurrentParty } from "#src/hooks/useParty.ts"; +import { getDebtTransferParticipantMatch } from "#src/lib/debtTransfer.ts"; +import { guardParticipatingInParty } from "#src/lib/guards.ts"; +import type { Party, PartyParticipant } from "#src/models/party.ts"; +import { Alert, AlertDescription, AlertTitle } from "#src/ui/Alert.tsx"; +import { Avatar } from "#src/ui/Avatar.tsx"; +import { Button } from "#src/ui/Button.tsx"; +import { Icon } from "#src/ui/Icon.tsx"; +import { IconButton } from "#src/ui/IconButton.tsx"; +import { cn } from "#src/ui/utils.ts"; + +interface TransferDebtSearchParams { + fromId: string; + toId: string; + amount: number; +} + +type TransferStep = "party" | "participant" | "confirm" | "success"; + +interface TransferDebtState { + destinationPartyId: string; + destinationParticipantId: string; + step: TransferStep; + partySelectionPrefilled: boolean; + participantSelectionPrefilled: boolean; + isSubmitting: boolean; +} + +type TransferDebtAction = + | { + type: "destinationPartySelected"; + partyId: string; + prefilledParticipantId: string; + } + | { type: "destinationParticipantSelected"; participantId: string } + | { type: "stepRequested"; step: TransferStep } + | { type: "submitStarted" } + | { type: "submitSucceeded" } + | { type: "submitFailed" }; + +interface DestinationPartyOption { + id: string; + entry: EligibleDebtTransferParty; + currentParticipant: PartyParticipant; + otherParticipants: PartyParticipant[]; + exactMatchParticipant: PartyParticipant | null; + recommendedParticipants: PartyParticipant[]; +} + +function getPrefilledParticipantId(option: DestinationPartyOption): string { + return option.otherParticipants.length === 1 + ? (option.otherParticipants[0]?.id ?? "") + : ""; +} + +function createInitialTransferDebtState( + destinationPartyOptions: DestinationPartyOption[], +): TransferDebtState { + const prefilledParty = + destinationPartyOptions.length === 1 ? destinationPartyOptions[0] : null; + const prefilledParticipantId = prefilledParty + ? getPrefilledParticipantId(prefilledParty) + : ""; + + if (!prefilledParty) { + return { + destinationPartyId: "", + destinationParticipantId: "", + step: "party", + partySelectionPrefilled: false, + participantSelectionPrefilled: false, + isSubmitting: false, + }; + } + + return { + destinationPartyId: prefilledParty.id, + destinationParticipantId: prefilledParticipantId, + step: prefilledParticipantId ? "confirm" : "participant", + partySelectionPrefilled: true, + participantSelectionPrefilled: prefilledParticipantId !== "", + isSubmitting: false, + }; +} + +function transferDebtReducer( + state: TransferDebtState, + action: TransferDebtAction, +): TransferDebtState { + switch (action.type) { + case "destinationPartySelected": + return { + ...state, + destinationPartyId: action.partyId, + destinationParticipantId: action.prefilledParticipantId, + step: action.prefilledParticipantId ? "confirm" : "participant", + partySelectionPrefilled: false, + participantSelectionPrefilled: action.prefilledParticipantId !== "", + }; + + case "destinationParticipantSelected": + return { + ...state, + destinationParticipantId: action.participantId, + step: "confirm", + participantSelectionPrefilled: false, + }; + + case "stepRequested": + return { + ...state, + step: action.step, + }; + + case "submitStarted": + return { + ...state, + isSubmitting: true, + }; + + case "submitSucceeded": + return { + ...state, + step: "success", + isSubmitting: false, + }; + + case "submitFailed": + return { + ...state, + isSubmitting: false, + }; + } +} + +function getVisibleTransferStep({ + step, + hasSelectedDestinationParty, + hasSelectedDestinationParticipant, +}: { + step: TransferStep; + hasSelectedDestinationParty: boolean; + hasSelectedDestinationParticipant: boolean; +}): TransferStep { + if (step === "success") { + return "success"; + } + + if (!hasSelectedDestinationParty) { + return "party"; + } + + if (step === "confirm" && !hasSelectedDestinationParticipant) { + return "participant"; + } + + return step; +} + +function getPreviousTransferStep({ + activeStep, + partySelectionPrefilled, + participantSelectionPrefilled, +}: { + activeStep: TransferStep; + partySelectionPrefilled: boolean; + participantSelectionPrefilled: boolean; +}): TransferStep | null { + if (activeStep === "confirm" && !participantSelectionPrefilled) { + return "participant"; + } + + if ( + (activeStep === "confirm" || activeStep === "participant") && + !partySelectionPrefilled + ) { + return "party"; + } + + return null; +} + +export const Route = createFileRoute("/party_/$partyId/transfer-debt")({ + component: RouteComponent, + pendingComponent: PartyPendingComponent, + validateSearch: (search): TransferDebtSearchParams => { + if ( + typeof search.fromId !== "string" || + typeof search.toId !== "string" || + typeof search.amount !== "number" || + search.amount <= 0 + ) { + throw new Error(t`Missing search params`); + } + + return { + fromId: search.fromId, + toId: search.toId, + amount: search.amount, + }; + }, + async loader({ context, params, location }) { + await guardParticipatingInParty(params.partyId, context, location); + }, +}); + +function RouteComponent() { + const { fromId, toId, amount } = Route.useSearch(); + const { party, transferDebtToParty } = useCurrentParty(); + const currentParticipant = useCurrentParticipant(); + const eligibleDestinationParties = useEligibleDebtTransferParties(); + const navigate = useNavigate(); + + const from = party.participants[fromId]; + const to = party.participants[toId]; + const isSupportedTransfer = fromId === currentParticipant.id; + + const destinationPartyOptions = useMemo(() => { + return eligibleDestinationParties.map((entry) => { + const otherParticipants = entry.otherParticipants; + const participantMatch = getDebtTransferParticipantMatch({ + sourceName: to?.name ?? "", + participants: otherParticipants, + }); + const exactMatchParticipant = + otherParticipants.find( + (participant) => + participant.id === participantMatch.exactMatchParticipantId, + ) ?? null; + const recommendedParticipants = participantMatch.recommendedParticipantIds + .map((participantId) => + otherParticipants.find( + (participant) => participant.id === participantId, + ), + ) + .filter( + (participant): participant is PartyParticipant => !!participant, + ); + + return { + id: entry.party.id, + entry, + currentParticipant: entry.currentParticipant, + otherParticipants, + exactMatchParticipant, + recommendedParticipants, + }; + }); + }, [eligibleDestinationParties, to?.name]); + + const [state, dispatch] = useReducer( + transferDebtReducer, + destinationPartyOptions, + createInitialTransferDebtState, + ); + const selectedDestinationParty = destinationPartyOptions.find( + ({ id }) => id === state.destinationPartyId, + ); + const destinationParticipants = + selectedDestinationParty?.otherParticipants ?? []; + const priorityDestinationParticipants = selectedDestinationParty + ? [ + selectedDestinationParty.exactMatchParticipant, + ...selectedDestinationParty.recommendedParticipants, + ].filter((participant): participant is PartyParticipant => !!participant) + : []; + const priorityDestinationParticipantIds = new Set( + priorityDestinationParticipants.map(({ id }) => id), + ); + const orderedDestinationParticipants = [ + ...priorityDestinationParticipants, + ...destinationParticipants.filter( + ({ id }) => !priorityDestinationParticipantIds.has(id), + ), + ]; + const hasSelectedDestinationParticipant = destinationParticipants.some( + ({ id }) => id === state.destinationParticipantId, + ); + const selectedDestinationParticipantId = hasSelectedDestinationParticipant + ? state.destinationParticipantId + : ""; + const selectedDestinationCounterparty = destinationParticipants.find( + (participant) => participant.id === selectedDestinationParticipantId, + ); + const canTransfer = + !!selectedDestinationParty && + !!selectedDestinationCounterparty && + selectedDestinationParticipantId !== ""; + + if (!from || !to) { + return ( + + + + ); + } + + if (!isSupportedTransfer) { + return ( + + + + ); + } + + const destinationPartyName = selectedDestinationParty?.entry.party.name ?? ""; + const destinationCurrentParticipantName = + selectedDestinationParty?.currentParticipant.name ?? ""; + const selectedDestinationCounterpartyName = + selectedDestinationCounterparty?.name ?? ""; + const activeStep = getVisibleTransferStep({ + step: state.step, + hasSelectedDestinationParty: !!selectedDestinationParty, + hasSelectedDestinationParticipant: !!selectedDestinationCounterparty, + }); + + const pageTitle = + activeStep === "confirm" + ? t`Confirm transfer` + : activeStep === "success" + ? t`Debt transferred` + : t`Transfer debt`; + const previousStep = getPreviousTransferStep({ + activeStep, + partySelectionPrefilled: state.partySelectionPrefilled, + participantSelectionPrefilled: state.participantSelectionPrefilled, + }); + const onBackPress = previousStep + ? () => { + dispatch({ type: "stepRequested", step: previousStep }); + } + : undefined; + + function scheduleSuccessRedirect(expenseId: string) { + window.setTimeout(() => { + void navigate({ + to: "/party/$partyId/expense/$expenseId", + params: { + partyId: party.id, + expenseId, + }, + replace: true, + }); + }, 1250); + } + + async function onConfirmTransfer() { + if (!selectedDestinationParty || !selectedDestinationCounterparty) { + return; + } + + try { + dispatch({ type: "submitStarted" }); + + const { originExpense } = await transferDebtToParty({ + destinationPartyId: selectedDestinationParty.entry.party.id, + originDebtorId: fromId, + originCreditorId: toId, + destinationDebtorId: selectedDestinationParty.currentParticipant.id, + destinationCreditorId: selectedDestinationCounterparty.id, + amount, + paidAt: new Date(), + originExpenseName: t`Debt transfer to another party`, + destinationExpenseName: t`Debt transfer from another party`, + }); + + dispatch({ type: "submitSucceeded" }); + scheduleSuccessRedirect(originExpense.id); + } catch { + dispatch({ type: "submitFailed" }); + toast.error(t`Failed to transfer debt`); + } + } + + return ( + + + {activeStep === "success" ? ( + + + + ) : activeStep === "confirm" ? ( + +
+ + + +
+
+ ) : activeStep === "participant" ? ( + +
+ + + {destinationParticipants.length === 0 ? ( + + ) : ( +
+ {orderedDestinationParticipants.map((participant) => ( + { + dispatch({ + type: "destinationParticipantSelected", + participantId: participant.id, + }); + }} + /> + ))} +
+ )} +
+
+ ) : ( + +
+ {destinationPartyOptions.length === 0 ? ( + + ) : ( + <> + + +
+ {destinationPartyOptions.map((option) => ( + { + dispatch({ + type: "destinationPartySelected", + partyId: option.id, + prefilledParticipantId: + getPrefilledParticipantId(option), + }); + }} + /> + ))} +
+ + )} +
+
+ )} +
+ + {activeStep === "success" ? null :
} + + ); +} + +function TransferDebtLayout({ + title, + children, + showBackButton = true, + onBackPress, +}: { + title: string; + children: React.ReactNode; + showBackButton?: boolean; + onBackPress?: () => void; +}) { + return ( +
+
+ {showBackButton ? ( + onBackPress ? ( + + ) : ( + + ) + ) : ( +
+ )} +

{title}

+
+ + {children} +
+ ); +} + +function SectionIntro({ + eyebrow, + title, + titleId, +}: { + eyebrow: string; + title: string; + titleId?: string; +}) { + return ( +
+
+ {eyebrow} +
+

+ {title} +

+
+ ); +} + +function DestinationPartyCard({ + option, + onPress, +}: { + option: DestinationPartyOption; + onPress: () => void; +}) { + const participantPreview = getParticipantPreview(option.otherParticipants); + const description = option.entry.party.description.trim(); + const hasDescription = description.length > 0; + const hasSupportingCopy = hasDescription || participantPreview !== null; + + return ( + + ); +} + +function ParticipantPreviewText({ + preview, +}: { + preview: { + names: string; + remainingCount: number; + }; +}) { + return ( + <> + {preview.names} + {preview.remainingCount > 0 ? ( + <> + {" "} + + + ) : null} + + ); +} + +function getParticipantPreview(participants: PartyParticipant[]) { + const visibleParticipantNames = participants + .filter( + (participant) => + !participant.isArchived && participant.name.trim() !== "", + ) + .map((participant) => participant.name.trim()); + + if (visibleParticipantNames.length === 0) { + return null; + } + + return { + mobile: getParticipantPreviewVariant(visibleParticipantNames, 2), + desktop: getParticipantPreviewVariant(visibleParticipantNames, 3), + }; +} + +function getParticipantPreviewVariant( + visibleParticipantNames: string[], + maxNames: number, +) { + const previewNames = visibleParticipantNames.slice(0, maxNames); + + return { + names: previewNames.join(", "), + remainingCount: visibleParticipantNames.length - previewNames.length, + }; +} + +function DestinationParticipantCard({ + participant, + isRecommended, + onPress, +}: { + participant: PartyParticipant; + isRecommended: boolean; + onPress: () => void; +}) { + return ( + + ); +} + +function TransferReviewCard({ + amount, + currency, + originParty, + destinationParty, + from, + to, + destinationDebtor, + destinationCreditor, +}: { + amount: number; + currency: Currency; + originParty: Party; + destinationParty?: Party; + from: PartyParticipant; + to: PartyParticipant; + destinationDebtor: PartyParticipant | null; + destinationCreditor: PartyParticipant | null; +}) { + const fromName = from.name; + const toName = to.name; + const destinationDebtorName = destinationDebtor?.name ?? ""; + const destinationCreditorName = destinationCreditor?.name ?? ""; + + return ( +
+
+
+
+ Debt being moved +
+
+ {fromName} {toName} +
+
+ + +
+ +
+ + + {fromName} + {" "} + stops owing{" "} + + {toName} + + + } + /> + + {destinationParty && destinationDebtor && destinationCreditor ? ( + + + {destinationCreditorName} + {" "} + is owed by{" "} + + {destinationDebtorName} + + + } + /> + ) : null} +
+
+ ); +} + +function ReviewPartyRow({ + caption, + party, + detail, +}: { + caption: string; + party: Party; + detail: React.ReactNode; +}) { + return ( +
+ + +
+
+
+ {caption} +
+
+
+
+ {party.name} +
+ +
+ {detail} +
+
+
+ ); +} + +function ReviewParticipantInline({ + participant, + children, +}: { + participant: PartyParticipant; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +function TransferSuccessState({ + destinationPartyName, + destinationCounterpartyName, + destinationDebtorName, +}: { + destinationPartyName: string; + destinationCounterpartyName: string; + destinationDebtorName: string; +}) { + return ( +
+
+ + + + + + + +
+ +

+ Debt transferred +

+ +

+ + The new debt is now in{" "} + {destinationPartyName}, where{" "} + {destinationCounterpartyName}{" "} + is owed by{" "} + {destinationDebtorName}. + +

+ +
+ + Opening updated expense… +
+
+ ); +} + +function TransferParticipantAvatar({ + participant, + className, +}: { + participant: PartyParticipant; + className?: string; +}) { + if (!participant.avatarId) { + return ; + } + + return ( + } + > + + + ); +} + +function TransferParticipantAvatarImage({ + avatarId, + name, + className, +}: { + avatarId: NonNullable; + name: string; + className?: string; +}) { + const { url } = useMediaFile(avatarId); + + return ; +} + +function PartySymbolBadge({ + party, + className, +}: { + party: Party; + className?: string; +}) { + const symbol = party.symbol || party.name.charAt(0).toUpperCase(); + + return ( +
+ {symbol} +
+ ); +} + +function InlineAlert({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+ + + {title} + {description} + +
+ ); +}