diff --git a/.changeset/pink-rockets-try.md b/.changeset/pink-rockets-try.md new file mode 100644 index 00000000..1477bb2c --- /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. diff --git a/.changeset/silver-steps-rest.md b/.changeset/silver-steps-rest.md new file mode 100644 index 00000000..aa9779c6 --- /dev/null +++ b/.changeset/silver-steps-rest.md @@ -0,0 +1,5 @@ +--- +"@trizum/pwa": patch +--- + +Simplify the debt transfer flow by separating the party, creditor, and confirmation screens and removing repeated transfer details. diff --git a/packages/pwa/e2e/debt-transfer.spec.ts b/packages/pwa/e2e/debt-transfer.spec.ts new file mode 100644 index 00000000..051401a4 --- /dev/null +++ b/packages/pwa/e2e/debt-transfer.spec.ts @@ -0,0 +1,126 @@ +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 debt", + ); + }, + ); + + await test.step( + "continue in the eligible destination party and choose the creditor", + async () => { + await originPartyPage.openSettlementActionButton( + originAction, + "Transfer debt", + ); + + 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, + ); + }, + ); + }); +}); diff --git a/packages/pwa/e2e/harness/scenarios.ts b/packages/pwa/e2e/harness/scenarios.ts index 777e34dd..3f127da2 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,28 @@ export function createExpenseLogFixture( })), }; } + +export function createDebtTransferDestinationFixture() { + 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, + }, + [debtTransferJourney.destinationCreditorParticipant.id]: { + ...debtTransferJourney.destinationCreditorParticipant, + }, + [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..460d628d 100644 --- a/packages/pwa/e2e/pages/party.page.ts +++ b/packages/pwa/e2e/pages/party.page.ts @@ -128,12 +128,32 @@ export class PartyPage { ).toBeVisible(); } + async expectSettlementActionButtonVisible( + action: SettlementAction, + buttonName: string, + ) { + await expect( + this.settlementActionCard(action).getByRole("button", { + name: buttonName, + }), + ).toBeVisible(); + } + 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 +163,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 debt)$/, + }), ).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..bb4ca04d --- /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 continueButton: Locator; + readonly confirmTransferButton: Locator; + + constructor(page: Page) { + this.page = page; + this.partyStep = page.getByTestId("transfer-debt-party-step"); + this.participantStep = page.getByTestId("transfer-debt-participant-step"); + this.confirmationStep = page.getByTestId("transfer-debt-confirmation-step"); + this.continueButton = page.getByRole("button", { + name: "Continue", + }); + this.confirmTransferButton = page.getByRole("button", { + name: "Confirm transfer", + }); + } + + async expectLoaded() { + await expect(this.page).toHaveURL(/\/party\/[^/]+\/transfer-debt\?.+/); + await expect( + this.page.getByRole("heading", { exact: true, name: "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 chooseDestinationParty(partyName: string) { + await this.partyStep + .locator("button") + .filter({ hasText: partyName }) + .first() + .click(); + await expect(this.participantStep).toBeVisible(); + } + + async expectParticipantStep() { + await expect(this.participantStep).toBeVisible(); + await expect(this.page.getByText("Choose who receives it")).toBeVisible(); + } + + async expectRecommendedParticipant(participantName: string) { + await expect( + this.participantStep.locator("button").filter({ hasText: participantName }), + ).toContainText("Recommended"); + } + + async chooseParticipant(participantName: string) { + await this.participantStep + .locator("button") + .filter({ hasText: participantName }) + .first() + .click(); + } + + async completeTransfer() { + await expect(this.continueButton).toBeVisible(); + await this.continueButton.click(); + await expect(this.confirmationStep).toBeVisible(); + await expect( + this.page.getByText("This will settle the debt", { exact: false }), + ).toBeVisible(); + await this.confirmTransferButton.click(); + } +} diff --git a/packages/pwa/locale/en/messages.po b/packages/pwa/locale/en/messages.po index c9c1f7e7..07f50b0a 100644 --- a/packages/pwa/locale/en/messages.po +++ b/packages/pwa/locale/en/messages.po @@ -15,20 +15,32 @@ 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" +#: src/routes/party_.$partyId.transfer-debt.tsx:802 +msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" +msgstr "{0, plural, one {# possible creditor} other {# possible creditors}}" + #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {and # other} other {and # others}}" +#: src/routes/party_.$partyId.transfer-debt.tsx:855 +msgid "{destinationCreditorName} is owed by {destinationDebtorName}" +msgstr "{destinationCreditorName} is owed by {destinationDebtorName}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:844 +msgid "{fromName} stops owing {toName}" +msgstr "{fromName} stops owing {toName}" + #: src/routes/migrate_.tricount.tsx:377 msgid "{message}" msgstr "{message}" @@ -82,8 +94,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 +104,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" @@ -243,6 +255,10 @@ msgstr "Avatar updated successfully" msgid "Azerbaijani Manat" msgstr "Azerbaijani Manat" +#: src/routes/party_.$partyId.transfer-debt.tsx:395 +msgid "Back" +msgstr "Back" + #: src/routes/archived.tsx:119 msgid "Back to home" msgstr "Back to home" @@ -259,15 +275,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" @@ -291,6 +307,14 @@ msgstr "Belize Dollar" msgid "Bermudan Dollar" msgstr "Bermudan Dollar" +#: src/routes/party_.$partyId.transfer-debt.tsx:710 +msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" +msgstr "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:717 +msgid "Best match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "Best match for {sourceCreditorName}: <0>{topRecommendationName}" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Bhutanese Ngultrum" @@ -407,6 +431,10 @@ msgstr "Changes sync automatically across all your devices" msgid "Check for updates" msgstr "Check for updates" +#: src/routes/party_.$partyId.transfer-debt.tsx:349 +msgid "Check the parties and participants before creating the two expenses." +msgstr "Check the parties and participants before creating the two expenses." + #: src/routes/index.tsx:82 msgid "Checking for updates..." msgstr "Checking for updates..." @@ -423,10 +451,54 @@ msgstr "Chilean Unit of Account (UF)" msgid "Chinese Yuan" msgstr "Chinese Yuan" +#: src/routes/party_.$partyId.transfer-debt.tsx:648 +msgid "Choose" +msgstr "Choose" + +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Choose a destination party" +msgstr "Choose a destination party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:578 +msgid "Choose a party" +msgstr "Choose a party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:580 +msgid "Choose a person" +msgstr "Choose a person" + #: 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:260 +msgid "Choose the participant who should receive the transferred debt" +msgstr "Choose the participant who should receive the transferred debt" + +#: src/routes/party_.$partyId.transfer-debt.tsx:232 +msgid "Choose the party where this debt should continue" +msgstr "Choose the party where this debt should continue" + +#: src/routes/party_.$partyId.transfer-debt.tsx:722 +msgid "Choose the person on the next step" +msgstr "Choose the person on the next step" + +#: src/routes/party_.$partyId.transfer-debt.tsx:449 +msgid "Choose where the debt should continue" +msgstr "Choose where the debt should continue" + +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +msgid "Choose who receives it" +msgstr "Choose who receives it" + +#: src/routes/party_.$partyId.transfer-debt.tsx:471 +msgid "Choose who receives the debt there" +msgstr "Choose who receives the debt there" + +#: src/routes/party_.$partyId.transfer-debt.tsx:275 +msgid "Choose who should be owed after the transfer." +msgstr "Choose who should be owed after the transfer." + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Clear all" @@ -484,6 +556,24 @@ msgstr "Complete your profile" msgid "Configure Profile" msgstr "Configure Profile" +#: src/routes/party_.$partyId.transfer-debt.tsx:581 +msgid "Confirm" +msgstr "Confirm" + +#: src/routes/party_.$partyId.transfer-debt.tsx:348 +msgid "Confirm this transfer" +msgstr "Confirm this transfer" + +#: src/routes/party_.$partyId.transfer-debt.tsx:269 +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +#: src/routes/party_.$partyId.transfer-debt.tsx:407 +msgid "Confirm transfer" +msgstr "Confirm transfer" + +#: src/routes/party_.$partyId.transfer-debt.tsx:400 +msgid "Confirming..." +msgstr "Confirming..." + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Congolese Franc" @@ -492,6 +582,10 @@ msgstr "Congolese Franc" msgid "Contact Us" msgstr "Contact Us" +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Continue" +msgstr "Continue" + #: src/routes/about.tsx:130 msgid "Contributors" msgstr "Contributors" @@ -517,6 +611,14 @@ 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:382 +msgid "Creates the same debt in the selected party" +msgstr "Creates the same debt in the selected party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:426 +msgid "Creditor" +msgstr "Creditor" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Credits" @@ -564,6 +666,10 @@ msgstr "Danish Krone" msgid "Date" msgstr "Date" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +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 +678,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:305 +msgid "Debt transfer from another party" +msgstr "Debt transfer from another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:304 +msgid "Debt transfer to another party" +msgstr "Debt transfer to another party" + +#: src/routes/party_.$partyId.transfer-debt.tsx:271 +#: src/routes/party_.$partyId.transfer-debt.tsx:929 +msgid "Debt transferred" +msgstr "Debt transferred" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Decimal point" @@ -589,6 +708,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:493 +msgid "Destination party" +msgstr "Destination party" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "Did you know?" @@ -605,6 +728,10 @@ msgstr "Djiboutian Franc" msgid "Dominican Peso" msgstr "Dominican Peso" +#: src/routes/party_.$partyId.transfer-debt.tsx:655 +msgid "Done" +msgstr "Done" + #: src/routes/new.tsx:590 msgid "East Caribbean Dollar" msgstr "East Caribbean Dollar" @@ -695,6 +822,14 @@ msgstr "Everything is archived for now. You can reopen any party from the archiv msgid "Exact" msgstr "Exact" +#: src/routes/party_.$partyId.transfer-debt.tsx:773 +msgid "Exact match" +msgstr "Exact match" + +#: src/routes/party_.$partyId.transfer-debt.tsx:479 +msgid "Exact name match selected automatically: {selectedExactMatchParticipantName}" +msgstr "Exact name match selected automatically: {selectedExactMatchParticipantName}" + #: src/routes/party_.$partyId.add.tsx:97 msgid "Expense added" msgstr "Expense added" @@ -711,7 +846,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" @@ -719,6 +854,10 @@ msgstr "Expenses" msgid "Expenses counted" msgstr "Expenses counted" +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +msgid "Expenses that will be created" +msgstr "Expenses that will be created" + #: src/components/QRCodeScanner.tsx:120 msgid "Failed to access camera" msgstr "Failed to access camera" @@ -751,6 +890,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:312 +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 +963,14 @@ msgid "Go back" msgstr "Go back" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:542 msgid "Go Back" msgstr "Go Back" +#: src/routes/party_.$partyId.transfer-debt.tsx:243 +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 +1007,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 +1040,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 +1064,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/>" @@ -940,6 +1088,10 @@ msgstr "Importing attachments ({0} of {1})" msgid "Importing Tricount data..." msgstr "Importing Tricount data..." +#: src/routes/party_.$partyId.transfer-debt.tsx:274 +msgid "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." +msgstr "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." + #: src/components/ExpenseEditor.tsx:469 msgid "Include all" msgstr "Include all" @@ -1063,7 +1215,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" @@ -1087,6 +1239,10 @@ msgstr "Libyan Dinar" msgid "License" msgstr "License" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" + #: src/routes/join.tsx:179 msgid "Link or code" msgstr "Link or code" @@ -1137,7 +1293,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 +1319,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 +1346,7 @@ msgid "Migration successful" msgstr "Migration successful" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:59 msgid "Missing search params" msgstr "Missing search params" @@ -1213,6 +1370,10 @@ msgstr "Move cursor left" msgid "Move cursor right" msgstr "Move cursor right" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "Move this debt into one of your other active parties." +msgstr "Move this debt into one of your other active parties." + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Mozambican Metical" @@ -1231,7 +1392,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,10 +1465,18 @@ msgstr "No camera found on this device" msgid "No currency" msgstr "No currency" +#: src/routes/party_.$partyId.transfer-debt.tsx:487 +msgid "No destination party available" +msgstr "No destination party available" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No emoji found" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +msgid "No obvious match for {sourceCreditorName} yet" +msgstr "No obvious match for {sourceCreditorName} yet" + #: src/components/PartyStatsView.tsx:299 #: src/components/PartyStatsView.tsx:513 msgid "No spending in this timeframe" @@ -1325,7 +1494,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:433 +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 +1538,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:253 +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 +1570,10 @@ msgstr "Open last party on launch" msgid "Open parenthesis" msgstr "Open parenthesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:944 +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 +1582,17 @@ msgstr "Optional description for this expense" msgid "or enter code" msgstr "or enter code" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party_.$partyId.transfer-debt.tsx:373 +msgid "Origin party" +msgstr "Origin party" + +#: 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.transfer-debt.tsx:636 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "owes" @@ -1471,6 +1657,10 @@ msgstr "participants" msgid "Participants" msgstr "Participants" +#: src/routes/party_.$partyId.transfer-debt.tsx:450 +msgid "Parties show your identity there and the best match we can find for {sourceCreditorName}." +msgstr "Parties show your identity there and the best match we can find for {sourceCreditorName}." + #: src/components/PartyListCard.tsx:248 msgid "Party actions" msgstr "Party actions" @@ -1532,15 +1722,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" @@ -1568,6 +1758,10 @@ msgstr "Phone number is required" msgid "Phone number must be less than 20 characters" msgstr "Phone number must be less than 20 characters" +#: src/routes/party_.$partyId.transfer-debt.tsx:472 +msgid "Pick the person in {destinationPartyName} who should be owed after the transfer." +msgstr "Pick the person in {destinationPartyName} who should be owed after the transfer." + #: src/routes/index.tsx:312 msgid "Pin party" msgstr "Pin party" @@ -1620,6 +1814,10 @@ msgstr "Point your camera at a trizum QR code" msgid "Polish Zloty" msgstr "Polish Zloty" +#: src/routes/party_.$partyId.transfer-debt.tsx:561 +msgid "Preview" +msgstr "Preview" + #: src/components/MediaGallery.tsx:194 msgid "Previous" msgstr "Previous" @@ -1632,6 +1830,10 @@ msgstr "Privacy Policy" msgid "Qatari Rial" msgstr "Qatari Rial" +#: src/routes/party_.$partyId.transfer-debt.tsx:490 +msgid "Quick recommendations" +msgstr "Quick recommendations" + #: src/components/PartyStatsView.tsx:240 msgid "Ranking based on how much each participant paid in the selected timeframe." msgstr "Ranking based on how much each participant paid in the selected timeframe." @@ -1644,6 +1846,18 @@ msgstr "Real-Time Sync" msgid "Receipt Attachments" msgstr "Receipt Attachments" +#: src/routes/party_.$partyId.transfer-debt.tsx:278 +msgid "Recommendations" +msgstr "Recommendations" + +#: src/routes/party_.$partyId.transfer-debt.tsx:782 +msgid "Recommended" +msgstr "Recommended" + +#: src/routes/party_.$partyId.transfer-debt.tsx:852 +msgid "Recreated in" +msgstr "Recreated in" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Reload" @@ -1681,6 +1895,14 @@ msgstr "Restore to home" msgid "Retry" msgstr "Retry" +#: src/routes/party_.$partyId.transfer-debt.tsx:366 +msgid "Review" +msgstr "Review" + +#: src/routes/party_.$partyId.transfer-debt.tsx:603 +msgid "Review transfer" +msgstr "Review transfer" + #: src/routes/new.tsx:441 msgid "Romanian Leu" msgstr "Romanian Leu" @@ -1735,7 +1957,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" @@ -1743,6 +1965,10 @@ msgstr "See spending totals and rankings" msgid "Select emoji" msgstr "Select emoji" +#: src/routes/party_.$partyId.transfer-debt.tsx:894 +msgid "Selected" +msgstr "Selected" + #: src/routes/support.tsx:52 msgid "Send us an email and we'll get back to you as soon as possible." msgstr "Send us an email and we'll get back to you as soon as possible." @@ -1756,7 +1982,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 +1991,21 @@ msgstr "Settings" msgid "Settings saved" msgstr "Settings saved" +#: src/routes/party_.$partyId.transfer-debt.tsx:841 +msgid "Settled in" +msgstr "Settled in" + +#: src/routes/party_.$partyId.transfer-debt.tsx:376 +msgid "Settles the current debt" +msgstr "Settles the current debt" + #: 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 +2061,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,10 +2106,22 @@ 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" +#: src/routes/party_.$partyId.transfer-debt.tsx:586 +msgid "Step {currentStep} of {totalSteps}" +msgstr "Step {currentStep} of {totalSteps}" + +#: src/routes/party_.$partyId.transfer-debt.tsx:448 +msgid "Step 1" +msgstr "Step 1" + +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Step 2" +msgstr "Step 2" + #: src/routes/support.tsx:89 msgid "Submit a Request" msgstr "Submit a Request" @@ -1972,7 +2218,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 +2234,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:933 +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 +2260,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:242 +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,10 +2272,30 @@ 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:434 +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." +#: src/routes/party_.$partyId.transfer-debt.tsx:276 +msgid "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." +msgstr "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." + +#: src/routes/party_.$partyId.transfer-debt.tsx:296 +msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." +msgstr "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." + +#: src/routes/party_.$partyId.transfer-debt.tsx:316 +msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." +msgstr "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." + +#: src/routes/party_.$partyId.transfer-debt.tsx:566 +msgid "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." +msgstr "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -2067,6 +2341,17 @@ msgstr "Total spent" msgid "Total split:" msgstr "Total split:" +#: src/routes/party_.$partyId.transfer-debt.tsx:240 +#: src/routes/party_.$partyId.transfer-debt.tsx:251 +#: src/routes/party_.$partyId.transfer-debt.tsx:272 +#: src/routes/party.$partyId.tsx:1041 +msgid "Transfer debt" +msgstr "Transfer debt" + +#: src/routes/party_.$partyId.transfer-debt.tsx:196 +msgid "Transferring debt..." +msgstr "Transferring debt..." + #: src/routes/migrate_.tricount.tsx:218 msgid "Tricount key" msgstr "Tricount key" @@ -2248,7 +2533,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}" @@ -2273,6 +2558,10 @@ msgstr "Welcome to trizum" msgid "What is this party about?" msgstr "What is this party about?" +#: src/routes/party_.$partyId.transfer-debt.tsx:311 +msgid "What will happen" +msgstr "What will happen" + #: src/routes/settings.tsx:150 msgid "What's your preferred way to be addressed?" msgstr "What's your preferred way to be addressed?" @@ -2281,6 +2570,14 @@ msgstr "What's your preferred way to be addressed?" msgid "Who are you?" msgstr "Who are you?" +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "Who is {0} in this party?" +msgstr "Who is {0} in this party?" + +#: src/routes/party_.$partyId.transfer-debt.tsx:259 +msgid "Who is {sourceCreditorName} in this party?" +msgstr "Who is {sourceCreditorName} in this party?" + #: src/routes/new.tsx:279 msgid "Who is invited to this party? You can add more participants later." msgstr "Who is invited to this party? You can add more participants later." @@ -2309,19 +2606,35 @@ msgstr "Yemeni Rial" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." +#: src/routes/party_.$partyId.transfer-debt.tsx:249 +msgid "You are" +msgstr "You are" + +#: src/routes/party_.$partyId.transfer-debt.tsx:705 +msgid "You are {currentParticipantName}" +msgstr "You are {currentParticipantName}" + #: src/components/PartyPendingComponent.tsx:16 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:254 +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:488 +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..74e9f1a9 100644 --- a/packages/pwa/locale/es/messages.po +++ b/packages/pwa/locale/es/messages.po @@ -15,20 +15,32 @@ 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" +#: src/routes/party_.$partyId.transfer-debt.tsx:802 +msgid "{0, plural, one {# possible creditor} other {# possible creditors}}" +msgstr "" + #. placeholder {0}: preview.remainingCount #: src/components/PartyListCard.tsx:342 msgid "{0, plural, one {and # other} other {and # others}}" msgstr "{0, plural, one {y # más} other {y # más}}" +#: src/routes/party_.$partyId.transfer-debt.tsx:855 +msgid "{destinationCreditorName} is owed by {destinationDebtorName}" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:844 +msgid "{fromName} stops owing {toName}" +msgstr "" + #: src/routes/migrate_.tricount.tsx:377 msgid "{message}" msgstr "{message}" @@ -78,8 +90,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 +100,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" @@ -231,6 +243,10 @@ msgstr "Avatar actualizado correctamente" msgid "Azerbaijani Manat" msgstr "Manat Azerbaiyano" +#: src/routes/party_.$partyId.transfer-debt.tsx:395 +msgid "Back" +msgstr "" + #: src/routes/archived.tsx:119 msgid "Back to home" msgstr "Volver al inicio" @@ -247,15 +263,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" @@ -279,6 +295,14 @@ msgstr "Dólar Beliceño" msgid "Bermudan Dollar" msgstr "Dólar Bermudeño" +#: src/routes/party_.$partyId.transfer-debt.tsx:710 +msgid "Best match for {sourceCreditorName}: <0>{exactMatchParticipantName}" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:717 +msgid "Best match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "" + #: src/routes/new.tsx:473 msgid "Bhutanese Ngultrum" msgstr "Ngultrum Butanés" @@ -387,6 +411,10 @@ msgstr "Los cambios se sincronizan automáticamente en todos tus dispositivos" msgid "Check for updates" msgstr "Comprobar actualizaciones" +#: src/routes/party_.$partyId.transfer-debt.tsx:349 +msgid "Check the parties and participants before creating the two expenses." +msgstr "" + #: src/routes/new.tsx:481 msgid "Chilean Peso" msgstr "Peso Chileno" @@ -399,10 +427,54 @@ msgstr "Unidad de Fomento Chilena (UF)" msgid "Chinese Yuan" msgstr "Yuan Chino" +#: src/routes/party_.$partyId.transfer-debt.tsx:648 +msgid "Choose" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:494 +msgid "Choose a destination party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:578 +msgid "Choose a party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:580 +msgid "Choose a person" +msgstr "" + #: 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:260 +msgid "Choose the participant who should receive the transferred debt" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:232 +msgid "Choose the party where this debt should continue" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:722 +msgid "Choose the person on the next step" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:449 +msgid "Choose where the debt should continue" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:427 +msgid "Choose who receives it" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:471 +msgid "Choose who receives the debt there" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:275 +msgid "Choose who should be owed after the transfer." +msgstr "" + #: src/components/CalculatorToolbar.tsx:449 msgid "Clear all" msgstr "Borrar todo" @@ -460,6 +532,24 @@ msgstr "Completa tu perfil" msgid "Configure Profile" msgstr "Configurar perfil" +#: src/routes/party_.$partyId.transfer-debt.tsx:581 +msgid "Confirm" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:348 +msgid "Confirm this transfer" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:269 +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +#: src/routes/party_.$partyId.transfer-debt.tsx:407 +msgid "Confirm transfer" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:400 +msgid "Confirming..." +msgstr "" + #: src/routes/new.tsx:477 msgid "Congolese Franc" msgstr "Franco Congoleño" @@ -468,6 +558,10 @@ msgstr "Franco Congoleño" msgid "Contact Us" msgstr "Contacto" +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Continue" +msgstr "" + #: src/routes/about.tsx:130 msgid "Contributors" msgstr "Colaboradores" @@ -489,6 +583,14 @@ msgstr "Colón Costarricense" msgid "Create a new Party" msgstr "Crear un nuevo Grupo" +#: src/routes/party_.$partyId.transfer-debt.tsx:382 +msgid "Creates the same debt in the selected party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:426 +msgid "Creditor" +msgstr "" + #: src/routes/about.tsx:141 msgid "Credits" msgstr "Créditos" @@ -536,6 +638,10 @@ msgstr "Corona Danesa" msgid "Date" msgstr "Fecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +msgid "Debt being moved" +msgstr "" + #: src/routes/party_.$partyId.pay.tsx:70 msgid "Debt settled between {0} and {1}!" msgstr "¡Deuda saldada entre {0} y {1}!" @@ -544,6 +650,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:305 +msgid "Debt transfer from another party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:304 +msgid "Debt transfer to another party" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:271 +#: src/routes/party_.$partyId.transfer-debt.tsx:929 +msgid "Debt transferred" +msgstr "" + #: src/components/CalculatorToolbar.tsx:554 msgid "Decimal point" msgstr "Punto decimal" @@ -561,6 +680,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:493 +msgid "Destination party" +msgstr "" + #: src/components/PartyPendingComponent.tsx:99 msgid "Did you know?" msgstr "¿Sabías que?" @@ -577,6 +700,10 @@ msgstr "Franco Yibutiano" msgid "Dominican Peso" msgstr "Peso Dominicano" +#: src/routes/party_.$partyId.transfer-debt.tsx:655 +msgid "Done" +msgstr "" + #: src/routes/new.tsx:590 msgid "East Caribbean Dollar" msgstr "Dólar del Caribe Oriental" @@ -659,6 +786,14 @@ msgstr "Unidad de Cuenta Europea 9" msgid "Everything is archived for now. You can reopen any party from the archived screen whenever you need it." msgstr "Por ahora todo está archivado. Puedes reabrir cualquier grupo desde la pantalla de archivados cuando lo necesites." +#: src/routes/party_.$partyId.transfer-debt.tsx:773 +msgid "Exact match" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:479 +msgid "Exact name match selected automatically: {selectedExactMatchParticipantName}" +msgstr "" + #: src/routes/party_.$partyId.add.tsx:97 msgid "Expense added" msgstr "Gasto añadido" @@ -671,7 +806,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" @@ -679,6 +814,10 @@ msgstr "Gastos" msgid "Expenses counted" msgstr "Gastos contabilizados" +#: src/routes/party_.$partyId.transfer-debt.tsx:367 +msgid "Expenses that will be created" +msgstr "" + #: src/components/QRCodeScanner.tsx:120 msgid "Failed to access camera" msgstr "Error al acceder a la cámara" @@ -707,6 +846,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:312 +msgid "Failed to transfer debt" +msgstr "" + #: src/routes/party_.$partyId.expense.$expenseId_.edit.tsx:171 msgid "Failed to update expense" msgstr "Error al actualizar el gasto" @@ -776,9 +919,14 @@ msgid "Go back" msgstr "Volver" #: src/components/BackButton.tsx:20 +#: src/routes/party_.$partyId.transfer-debt.tsx:542 msgid "Go Back" msgstr "Volver" +#: src/routes/party_.$partyId.transfer-debt.tsx:243 +msgid "Go back and try again from the balances list." +msgstr "" + #: src/routes/migrate_.tricount.tsx:315 msgid "Go back to home" msgstr "Volver al inicio" @@ -811,7 +959,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 +988,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 +1016,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/>" @@ -892,6 +1040,10 @@ msgstr "Importando adjuntos ({0} de {1})" msgid "Importing Tricount data..." msgstr "Importando datos de Tricount..." +#: src/routes/party_.$partyId.transfer-debt.tsx:274 +msgid "In {destinationPartyName}, {destinationCurrentParticipantName} will owe the selected person." +msgstr "" + #: src/components/ExpenseEditor.tsx:469 msgid "Include all" msgstr "Incluir todos" @@ -1011,7 +1163,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" @@ -1035,6 +1187,10 @@ msgstr "Dinar Libio" msgid "License" msgstr "Licencia" +#: src/routes/party_.$partyId.transfer-debt.tsx:820 +msgid "Likely match for {sourceCreditorName}: <0>{topRecommendationName}" +msgstr "" + #: src/routes/join.tsx:179 msgid "Link or code" msgstr "Enlace o código" @@ -1085,7 +1241,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 +1267,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 +1294,7 @@ msgid "Migration successful" msgstr "Migración exitosa" #: src/routes/party_.$partyId.pay.tsx:24 +#: src/routes/party_.$partyId.transfer-debt.tsx:59 msgid "Missing search params" msgstr "Faltan parámetros de búsqueda" @@ -1161,6 +1318,10 @@ msgstr "Mover cursor a la izquierda" msgid "Move cursor right" msgstr "Mover cursor a la derecha" +#: src/routes/party_.$partyId.transfer-debt.tsx:495 +msgid "Move this debt into one of your other active parties." +msgstr "" + #: src/routes/new.tsx:538 msgid "Mozambican Metical" msgstr "Metical Mozambiqueño" @@ -1179,7 +1340,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,10 +1413,18 @@ msgstr "No se encontró cámara en este dispositivo" msgid "No currency" msgstr "Sin moneda" +#: src/routes/party_.$partyId.transfer-debt.tsx:487 +msgid "No destination party available" +msgstr "" + #: src/components/EmojiPicker.tsx:333 msgid "No emoji found" msgstr "No se encontraron emojis" +#: src/routes/party_.$partyId.transfer-debt.tsx:825 +msgid "No obvious match for {sourceCreditorName} yet" +msgstr "" + #: src/components/PartyStatsView.tsx:299 #: src/components/PartyStatsView.tsx:513 msgid "No spending in this timeframe" @@ -1269,7 +1438,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:433 +msgid "Nobody else is available in this party" +msgstr "" + +#: src/routes/party.$partyId.tsx:858 msgid "Nobody owes you money!" msgstr "¡Nadie te debe dinero!" @@ -1309,10 +1482,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:253 +msgid "Only your own debt can be transferred" +msgstr "" + #: src/routes/index.tsx:377 msgid "Open archived parties" msgstr "Abrir grupos archivados" @@ -1337,16 +1514,25 @@ msgstr "Abrir el último grupo al abrir la app" msgid "Open parenthesis" msgstr "Abrir paréntesis" +#: src/routes/party_.$partyId.transfer-debt.tsx:944 +msgid "Opening updated expense…" +msgstr "" + #: src/routes/join.tsx:157 msgid "or enter code" msgstr "o introduce código" -#: src/routes/party.$partyId.tsx:870 +#: src/routes/party_.$partyId.transfer-debt.tsx:373 +msgid "Origin party" +msgstr "" + +#: 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.transfer-debt.tsx:636 +#: src/routes/party.$partyId.tsx:986 msgid "owes" msgstr "le debe" @@ -1411,6 +1597,10 @@ msgstr "participantes" msgid "Participants" msgstr "Participantes" +#: src/routes/party_.$partyId.transfer-debt.tsx:450 +msgid "Parties show your identity there and the best match we can find for {sourceCreditorName}." +msgstr "" + #: src/components/PartyListCard.tsx:248 msgid "Party actions" msgstr "Acciones del grupo" @@ -1464,15 +1654,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" @@ -1500,6 +1690,10 @@ msgstr "Se requiere el número de teléfono" msgid "Phone number must be less than 20 characters" msgstr "El número de teléfono debe tener menos de 20 caracteres" +#: src/routes/party_.$partyId.transfer-debt.tsx:472 +msgid "Pick the person in {destinationPartyName} who should be owed after the transfer." +msgstr "" + #: src/routes/index.tsx:312 msgid "Pin party" msgstr "Fijar grupo" @@ -1552,6 +1746,10 @@ msgstr "Apunta tu cámara a un código QR de trizum" msgid "Polish Zloty" msgstr "Zloty Polaco" +#: src/routes/party_.$partyId.transfer-debt.tsx:561 +msgid "Preview" +msgstr "" + #: src/components/MediaGallery.tsx:194 msgid "Previous" msgstr "Anterior" @@ -1564,6 +1762,10 @@ msgstr "Política de Privacidad" msgid "Qatari Rial" msgstr "Rial Catarí" +#: src/routes/party_.$partyId.transfer-debt.tsx:490 +msgid "Quick recommendations" +msgstr "" + #: src/components/PartyStatsView.tsx:240 msgid "Ranking based on how much each participant paid in the selected timeframe." msgstr "Clasificación según cuánto ha pagado cada participante en el período seleccionado." @@ -1572,6 +1774,18 @@ 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:278 +msgid "Recommendations" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:782 +msgid "Recommended" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:852 +msgid "Recreated in" +msgstr "" + #: src/components/UpdateController.tsx:28 msgid "Reload" msgstr "Recargar" @@ -1609,6 +1823,14 @@ msgstr "Restaurar al inicio" msgid "Retry" msgstr "Reintentar" +#: src/routes/party_.$partyId.transfer-debt.tsx:366 +msgid "Review" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:603 +msgid "Review transfer" +msgstr "" + #: src/routes/new.tsx:441 msgid "Romanian Leu" msgstr "Leu Rumano" @@ -1663,7 +1885,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" @@ -1671,6 +1893,10 @@ msgstr "Ver totales y clasificación de gasto" msgid "Select emoji" msgstr "Seleccionar emoji" +#: src/routes/party_.$partyId.transfer-debt.tsx:894 +msgid "Selected" +msgstr "" + #: src/routes/support.tsx:52 msgid "Send us an email and we'll get back to you as soon as possible." msgstr "Envíanos un email y te responderemos lo antes posible." @@ -1684,7 +1910,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 +1919,21 @@ msgstr "Configuración" msgid "Settings saved" msgstr "Configuración guardada" +#: src/routes/party_.$partyId.transfer-debt.tsx:841 +msgid "Settled in" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:376 +msgid "Settles the current debt" +msgstr "" + #: 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 +1985,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,10 +2030,22 @@ 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" +#: src/routes/party_.$partyId.transfer-debt.tsx:586 +msgid "Step {currentStep} of {totalSteps}" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:448 +msgid "Step 1" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:470 +msgid "Step 2" +msgstr "" + #: src/routes/support.tsx:89 msgid "Submit a Request" msgstr "Enviar sugerencia" @@ -1896,7 +2142,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 +2158,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:933 +msgid "The new debt is now in <0>{destinationPartyName}, where <1>{destinationCounterpartyName} is owed by <2>{destinationDebtorName}." +msgstr "" + #: 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 +2184,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:242 +msgid "This debt is no longer available" +msgstr "" + #: 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,10 +2196,30 @@ 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:434 +msgid "This party needs another active participant besides you to receive the transferred debt." +msgstr "" + #: 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." +#: src/routes/party_.$partyId.transfer-debt.tsx:276 +msgid "This will settle the debt in {originPartyName} and recreate it in {destinationPartyName}." +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:296 +msgid "This will settle the debt in <0>{0} and create the same debt in <1>{1}, where <2>{2} is owed by <3>{3}." +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:316 +msgid "This will settle the debt in <0>{originPartyName} and create the same debt in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} is owed by <3>{destinationCurrentParticipantName}." +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:566 +msgid "This will settle the debt in <0>{originPartyName} and recreate it in <1>{destinationPartyName}, where <2>{selectedDestinationCounterpartyName} will be owed by <3>{destinationCurrentParticipantName}." +msgstr "" + #. Label for the timeframe filter on the party stats screen #: src/components/PartyStatsView.tsx:270 msgid "Timeframe" @@ -1987,6 +2261,17 @@ msgstr "Total pagado por cada participante en el período seleccionado." msgid "Total spent" msgstr "Total gastado" +#: src/routes/party_.$partyId.transfer-debt.tsx:240 +#: src/routes/party_.$partyId.transfer-debt.tsx:251 +#: src/routes/party_.$partyId.transfer-debt.tsx:272 +#: src/routes/party.$partyId.tsx:1041 +msgid "Transfer debt" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:196 +msgid "Transferring debt..." +msgstr "" + #: src/routes/migrate_.tricount.tsx:57 msgid "Tricount key or URL is required" msgstr "Se requiere la clave o URL de Tricount" @@ -2151,7 +2436,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}" @@ -2172,6 +2457,10 @@ msgstr "Bienvenid@ a trizum" msgid "What is this party about?" msgstr "¿De qué trata este grupo?" +#: src/routes/party_.$partyId.transfer-debt.tsx:311 +msgid "What will happen" +msgstr "" + #: src/routes/settings.tsx:150 msgid "What's your preferred way to be addressed?" msgstr "¿Cuál es tu forma preferida de que te llamen?" @@ -2180,6 +2469,14 @@ msgstr "¿Cuál es tu forma preferida de que te llamen?" msgid "Who are you?" msgstr "¿Quién eres?" +#: src/routes/party_.$partyId.transfer-debt.tsx:239 +msgid "Who is {0} in this party?" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:259 +msgid "Who is {sourceCreditorName} in this party?" +msgstr "" + #: src/routes/new.tsx:279 msgid "Who is invited to this party? You can add more participants later." msgstr "¿Quién está invitado a este grupo? Puedes añadir más participantes más tarde." @@ -2208,19 +2505,35 @@ msgstr "Rial Yemení" msgid "Yes! Your data is stored locally on your device and only synced with people you explicitly share your groups with. We don't have access to your expense data." msgstr "¡Sí! Tus datos se almacenan localmente en tu dispositivo y solo se sincronizan con las personas con las que compartes explícitamente tus grupos. No tenemos acceso a tus datos de gastos." +#: src/routes/party_.$partyId.transfer-debt.tsx:249 +msgid "You are" +msgstr "" + +#: src/routes/party_.$partyId.transfer-debt.tsx:705 +msgid "You are {currentParticipantName}" +msgstr "" + #: src/components/PartyPendingComponent.tsx:16 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:254 +msgid "You can only transfer debt from actions where you are the one who owes the money." +msgstr "" + +#: 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:488 +msgid "You need another active party with the same currency to transfer this debt." +msgstr "" + +#: 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.ts b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts new file mode 100644 index 00000000..6cd94a42 --- /dev/null +++ b/packages/pwa/src/hooks/useEligibleDebtTransferParties.ts @@ -0,0 +1,35 @@ +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"]; +} + +export function useEligibleDebtTransferParties(): EligibleDebtTransferParty[] { + const { party: originParty } = useCurrentParty(); + const { partyList } = usePartyList(); + const { activePartyIds } = getOrderedPartySections(partyList); + + const joinedActivePartyIds = activePartyIds.filter((partyId) => { + return ( + partyId !== originParty.id && + partyList.participantInParties[partyId] !== undefined + ); + }); + + const partyEntries = useMultipleSuspenseDocument(joinedActivePartyIds); + + return partyEntries + .map(({ doc }) => doc) + .filter((party): party is Party => { + return !!party && party.currency === originParty.currency; + }) + .map((party) => ({ + party, + currentParticipantId: partyList.participantInParties[party.id], + })); +} 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/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..9528e511 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,7 +999,7 @@ 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..b600b663 --- /dev/null +++ b/packages/pwa/src/routes/party_.$partyId.transfer-debt.tsx @@ -0,0 +1,1046 @@ +import { t } from "@lingui/core/macro"; +import { 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, useEffect, useMemo, useState } 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, + type DebtTransferParticipantMatch, +} 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 DestinationPartyOption { + id: string; + entry: EligibleDebtTransferParty; + currentParticipant: PartyParticipant; + otherParticipants: PartyParticipant[]; + participantMatch: DebtTransferParticipantMatch; + exactMatchParticipant: PartyParticipant | null; + recommendedParticipants: PartyParticipant[]; +} + +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 [destinationPartyId, setDestinationPartyId] = useState(""); + const [destinationParticipantId, setDestinationParticipantId] = + useState(""); + const [step, setStep] = useState("party"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successExpenseId, setSuccessExpenseId] = useState(null); + + const destinationPartyOptions = useMemo(() => { + return eligibleDestinationParties.flatMap((entry) => { + const currentDestinationParticipant = + entry.party.participants[entry.currentParticipantId]; + + if ( + !currentDestinationParticipant || + currentDestinationParticipant.isArchived + ) { + return []; + } + + const otherParticipants = Object.values(entry.party.participants) + .filter( + (participant) => + !participant.isArchived && + participant.id !== currentDestinationParticipant.id, + ) + .sort((left, right) => left.name.localeCompare(right.name)); + 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: currentDestinationParticipant, + otherParticipants, + participantMatch, + exactMatchParticipant, + recommendedParticipants, + }, + ]; + }); + }, [eligibleDestinationParties, to?.name]); + + const selectedDestinationParty = destinationPartyOptions.find( + ({ id }) => id === destinationPartyId, + ); + const hasPartyStep = destinationPartyOptions.length > 1; + const destinationParticipants = + selectedDestinationParty?.otherParticipants ?? []; + const selectedDestinationCounterparty = destinationParticipants.find( + (participant) => participant.id === destinationParticipantId, + ); + const canTransfer = + !!selectedDestinationParty && + !!selectedDestinationCounterparty && + destinationParticipantId !== ""; + + useEffect(() => { + if (destinationPartyOptions.length === 0) { + setDestinationPartyId(""); + return; + } + + if ( + destinationPartyId && + destinationPartyOptions.some((option) => option.id === destinationPartyId) + ) { + return; + } + + setDestinationPartyId( + destinationPartyOptions.length === 1 ? destinationPartyOptions[0].id : "", + ); + }, [destinationPartyId, destinationPartyOptions]); + + useEffect(() => { + if (!selectedDestinationParty) { + setDestinationParticipantId(""); + return; + } + + setDestinationParticipantId((currentValue) => { + if ( + selectedDestinationParty.otherParticipants.some( + (participant) => participant.id === currentValue, + ) + ) { + return currentValue; + } + + return ( + selectedDestinationParty.participantMatch.exactMatchParticipantId ?? "" + ); + }); + }, [selectedDestinationParty]); + + useEffect(() => { + if (step === "success") { + return; + } + + if (!selectedDestinationParty) { + setStep("party"); + return; + } + + if (step === "party" && !hasPartyStep) { + setStep("participant"); + } + }, [hasPartyStep, selectedDestinationParty, step]); + + useEffect(() => { + if (step === "confirm" && !canTransfer) { + setStep(selectedDestinationParty ? "participant" : "party"); + } + }, [canTransfer, selectedDestinationParty, step]); + + useEffect(() => { + if (step !== "success" || !successExpenseId) { + return; + } + + const timeoutId = window.setTimeout(() => { + void navigate({ + to: "/party/$partyId/expense/$expenseId", + params: { + partyId: party.id, + expenseId: successExpenseId, + }, + replace: true, + }); + }, 1250); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [navigate, party.id, step, successExpenseId]); + + if (!from || !to) { + return ( + + + + ); + } + + if (!isSupportedTransfer) { + return ( + + + + ); + } + + const sourceCreditorName = to.name; + const originPartyName = party.name; + const destinationPartyName = selectedDestinationParty?.entry.party.name ?? ""; + const destinationCurrentParticipantName = + selectedDestinationParty?.currentParticipant.name ?? ""; + const selectedDestinationCounterpartyName = + selectedDestinationCounterparty?.name ?? ""; + const pageTitle = + step === "confirm" + ? t`Confirm transfer` + : step === "success" + ? t`Debt transferred` + : t`Transfer debt`; + const participantStepDescription = selectedDestinationParty + ? t`In ${destinationPartyName}, ${destinationCurrentParticipantName} will owe the selected person.` + : t`Choose who should be owed after the transfer.`; + const reviewStepDescription = t`This will settle the debt in ${originPartyName} and recreate it in ${destinationPartyName}.`; + const onBackPress = + step === "confirm" + ? () => { + setStep("participant"); + } + : step === "participant" && hasPartyStep + ? () => { + setStep("party"); + } + : undefined; + + async function onConfirmTransfer() { + if (!selectedDestinationParty || !selectedDestinationCounterparty) { + return; + } + + try { + setIsSubmitting(true); + + 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`, + }); + + setSuccessExpenseId(originExpense.id); + setStep("success"); + } catch { + setIsSubmitting(false); + toast.error(t`Failed to transfer debt`); + } + } + + return ( + + {step === "party" || step === "participant" ? ( +
+ +
+ ) : null} + + {step !== "success" ? ( +
+ +
+ ) : null} + + + {step === "success" ? ( + + + + ) : step === "confirm" ? ( + +
+ + + + +
+ +
+
+
+ ) : step === "participant" ? ( + +
+ + + {destinationParticipants.length === 0 ? ( + + ) : ( +
+ {destinationParticipants.map((participant) => ( + candidate.id === participant.id, + ) ?? false + } + isExactMatch={ + selectedDestinationParty?.exactMatchParticipant?.id === + participant.id + } + isSelected={participant.id === destinationParticipantId} + participant={participant} + onPress={() => { + setDestinationParticipantId(participant.id); + }} + /> + ))} +
+ )} + + +
+
+ ) : ( + +
+ {destinationPartyOptions.length === 0 ? ( + + ) : ( + <> + + +
+ {destinationPartyOptions.map((option) => ( + { + setDestinationPartyId(option.id); + setStep("participant"); + }} + /> + ))} +
+ + )} +
+
+ )} +
+ + {step === "success" ? null :
} + + ); +} + +function TransferDebtLayout({ + title, + children, + showBackButton = true, + onBackPress, +}: { + title: string; + children: React.ReactNode; + showBackButton?: boolean; + onBackPress?: () => void; +}) { + return ( +
+
+ {showBackButton ? ( + onBackPress ? ( + + ) : ( + + ) + ) : ( +
+ )} +

{title}

+
+ + {children} +
+ ); +} + +function TransferStepIndicator({ + step, + hasPartyStep, +}: { + step: Exclude; + hasPartyStep: boolean; +}) { + const totalSteps = hasPartyStep ? 3 : 2; + const currentStep = + step === "party" + ? 1 + : step === "participant" + ? hasPartyStep + ? 2 + : 1 + : totalSteps; + const currentStepLabel = + step === "party" + ? t`Choose a party` + : step === "participant" + ? t`Choose a person` + : t`Confirm`; + + return ( +
+ + + Step {currentStep} of {totalSteps} + + + + {currentStepLabel} +
+ ); +} + +function SectionIntro({ + eyebrow, + title, + description, +}: { + eyebrow: string; + title: string; + description: string; +}) { + return ( +
+
+ {eyebrow} +
+

{title}

+

+ {description} +

+
+ ); +} + +function DebtSummaryCard({ + fromName, + toName, + amount, + currency, +}: { + fromName: string; + toName: string; + amount: number; + currency: Currency; +}) { + return ( +
+
+ + {fromName} + + + owes + + + {toName} + +
+ +
+ +
+
+ ); +} + +function DestinationPartyCard({ + option, + isSelected, + sourceCreditorName, + onPress, +}: { + option: DestinationPartyOption; + isSelected: boolean; + sourceCreditorName: string; + onPress: () => void; +}) { + const topRecommendation = option.recommendedParticipants[0] ?? null; + const currentParticipantName = option.currentParticipant.name; + const exactMatchParticipantName = option.exactMatchParticipant?.name ?? ""; + const topRecommendationName = topRecommendation?.name ?? ""; + + return ( + + ); +} + +function DestinationParticipantCard({ + participant, + isSelected, + isRecommended, + isExactMatch, + onPress, +}: { + participant: PartyParticipant; + isSelected: boolean; + isRecommended: boolean; + isExactMatch: boolean; + onPress: () => void; +}) { + return ( + + ); +} + +function TransferReviewCard({ + amount, + currency, + fromName, + toName, + originParty, + destinationParty, + destinationDebtorName, + destinationCreditorName, +}: { + amount: number; + currency: Currency; + fromName: string; + toName: string; + originParty: Party; + destinationParty?: Party; + destinationDebtorName: string; + destinationCreditorName: string; +}) { + return ( +
+
+
+
+ Debt being moved +
+
+ {fromName} {toName} +
+
+ + +
+ +
+ + {fromName} stops owing {toName} + + } + /> + + {destinationParty ? ( + + {destinationCreditorName} is owed by {destinationDebtorName} + + } + /> + ) : null} +
+
+ ); +} + +function ReviewPartyRow({ + caption, + party, + detail, +}: { + caption: string; + party: Party; + detail: React.ReactNode; +}) { + return ( +
+ + +
+
+ {caption} +
+
+ {party.name} +
+
+ {detail} +
+
+
+ ); +} + +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 InfoPill({ + children, + tone = "default", +}: { + children: React.ReactNode; + tone?: "accent" | "default"; +}) { + return ( + + {children} + + ); +} + +function InlineAlert({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+ + + {title} + {description} + +
+ ); +}