Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3b2266a
feat(payments): redesign the Payments overview, Transactions, Request…
arseniy-nikitochkin Sep 29, 2026
d641290
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Sep 29, 2026
c7e1866
feat(payments): put the rest of Payments behind its own new-design-ac…
arseniy-nikitochkin Sep 29, 2026
0fa90da
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
c80bc14
test(payments): accept the activity list's empty-state link in the co…
arseniy-nikitochkin Oct 1, 2026
19597de
refactor(payments): drop the redesigned copy of the ramp complete screen
arseniy-nikitochkin Oct 1, 2026
0683fb0
refactor(payments): share the redesigned list tables' heading row
arseniy-nikitochkin Oct 1, 2026
93d4700
fix(payments): free the New request form when creating the link throws
arseniy-nikitochkin Oct 1, 2026
ed0845a
fix(payments): page the Requests list on the server and find any requ…
arseniy-nikitochkin Oct 1, 2026
addbe59
fix(payments): clear a schedule's token when its new funding wallet l…
arseniy-nikitochkin Oct 1, 2026
4f95f32
test(payments): follow the redesigned schedule pages in the recurring…
arseniy-nikitochkin Oct 1, 2026
7200f42
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
20919f2
fix(payments): search every request and match reconciled status in th…
arseniy-nikitochkin Oct 1, 2026
67fb26e
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
0f75bae
fix(payments): read every request unfiltered so the Requests scan ski…
arseniy-nikitochkin Oct 1, 2026
4711d54
fix(i18n): stop adding the playground and nav entries to the copy bas…
arseniy-nikitochkin Oct 1, 2026
ee2d96c
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
ae0df85
fix(payments): find requests older than the newest 500 in a status se…
arseniy-nikitochkin Oct 1, 2026
ecb8fc8
refactor(payments): reset the New request busy flag unconditionally i…
arseniy-nikitochkin Oct 1, 2026
f9b2411
refactor(payments): split the Requests list workspace into smaller pi…
arseniy-nikitochkin Oct 1, 2026
a033fa3
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
5738d05
fix(payments): read Awaiting payment's pages last-first so settles sk…
arseniy-nikitochkin Oct 1, 2026
131da4d
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
a3a0030
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
20e3dbe
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
3fa61f6
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
a9257f1
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
bb373fa
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
2fa5532
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
62799d2
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
fd8c74a
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
bf205a3
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
2b0259c
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 1, 2026
5147f15
refactor(web): build New schedule's wallet and amount fields from the…
arseniy-nikitochkin Oct 1, 2026
2014505
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
48316a5
fix(payments): turn off Requests search until the API can search ever…
arseniy-nikitochkin Oct 2, 2026
4ba9f28
fix(payments): page the Requests list straight from the API
arseniy-nikitochkin Oct 2, 2026
eb54dab
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
57be482
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
a18c690
docs(payments): note how the Awaiting payment total can run high unti…
arseniy-nikitochkin Oct 2, 2026
1807827
fix(payments): search the Requests page again and show Awaiting payme…
arseniy-nikitochkin Oct 2, 2026
081f6af
Update apps/sdp-web/src/app/dashboard/payments/requests/payment-reque…
arseniy-nikitochkin Oct 2, 2026
a8ab500
fix(payments): space the NEW DESIGN overview as the mockup measures
arseniy-nikitochkin Oct 2, 2026
37dd397
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
2be5652
Merge origin/feat/payments-redesign-4-activity into feat/payments-red…
arseniy-nikitochkin Oct 2, 2026
6791bb6
style(payments): format the Requests search matcher
arseniy-nikitochkin Oct 2, 2026
7027585
fix(payments): set a record section 54px under the rows before it, as…
arseniy-nikitochkin Oct 2, 2026
9d5cc62
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
448d497
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
7583bd7
Merge branch 'feat/payments-redesign-3-pay-deposit' into feat/payment…
arseniy-nikitochkin Oct 2, 2026
437e4af
Merge branch 'feat/payments-redesign-4a-transactions' into feat/payme…
arseniy-nikitochkin Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test.describe("payments command center and transaction ledger", () => {
const destinations = [
["Pay", "/dashboard/payments/pay"],
["Deposit", "/dashboard/payments/deposit"],
["Request payment", "/dashboard/payments/requests"],
["Request", "/dashboard/payments/requests"],
["Schedule", "/dashboard/payments/recurring/create"],
] as const;
for (const [name, href] of destinations) {
Expand All @@ -38,15 +38,20 @@ test.describe("payments command center and transaction ledger", () => {
).toHaveAttribute("href", href);
}

for (const section of ["balance", "activity", "upcoming", "network"]) {
for (const section of ["balance", "summary", "actions", "activity"]) {
await expect(
commandCenter.locator(`[data-payments-overview-section="${section}"]`)
).toBeVisible({
timeout: 120_000,
});
}
// With transfers the activity list ends on "View all transactions"; a project with none
// shows the empty state, whose one way on is "Open Transactions".
await expect(
commandCenter.getByRole("link", { name: "View all transactions" })
commandCenter
.locator('[data-payments-overview-section="activity"]')
.getByRole("link", { name: /^(View all transactions|Open Transactions)$/ })
.first()
).toHaveAttribute("href", "/dashboard/payments/transactions");
});

Expand All @@ -57,7 +62,7 @@ test.describe("payments command center and transaction ledger", () => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/dashboard/payments/transactions", { waitUntil: "domcontentloaded" });

const search = page.getByRole("textbox", { name: /search transactions/i });
const search = page.getByRole("searchbox", { name: /search transactions/i });
await expect(search).toBeVisible();
await search.fill("invoice-42");
await search.press("Enter");
Expand Down
78 changes: 46 additions & 32 deletions apps/sdp-web/playwright/tests/payments-recurring.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,56 +64,62 @@ test.describe
test("creates and displays a recurring payment", async ({ page }) => {
await page.goto("/dashboard/payments");

await expect(page.getByRole("link", { name: "Recurring", exact: true })).toBeVisible();
await page.getByRole("link", { name: "Recurring", exact: true }).click();
await expect(page.getByRole("link", { name: "Scheduled", exact: true })).toBeVisible();
await page.getByRole("link", { name: "Scheduled", exact: true }).click();
await expect(page).toHaveURL(/\/dashboard\/payments\/recurring$/);
await expect(
page.locator("main").getByRole("heading", { name: "Recurring payments" }).first()
page.locator("main").getByRole("heading", { name: "Schedules" }).first()
).toBeVisible();
await expect(
page.getByText("No recurring payments yet.").or(page.locator("tbody tr").first())
page.getByText("You haven't created a schedule yet").or(page.locator("tbody tr").first())
).toBeVisible({ timeout: 120_000 });

await page.getByRole("link", { name: "Create recurring payment" }).first().click();
await page.getByRole("link", { name: "New", exact: true }).click();
await expect(page).toHaveURL(/\/dashboard\/payments\/recurring\/create$/);

const app = page.locator("main");
const next = app.getByRole("button", { name: "Next", exact: true });
const next = app.getByRole("button", { name: "Continue", exact: true });

await app.getByRole("button", { name: "Counterparty", exact: true }).click();
await page.getByPlaceholder("Search counterparties").fill(recurringCounterpartyName);
// Payment: the contact's only Solana address is the destination; a contact with several
// gets a Destination field instead.
await app.getByRole("button", { name: "Contact", exact: true }).click();
await page.getByPlaceholder("Search contacts").fill(recurringCounterpartyName);
await page.getByRole("button", { name: recurringCounterpartyName }).click();
await expect(next).toBeEnabled({ timeout: 120_000 });
await next.click();

await app.getByRole("button", { name: "Destination account", exact: true }).click();
await page.getByRole("button", { name: recurringAccountLabel }).click();
await expect(next).toBeEnabled({ timeout: 120_000 });
await next.click();
const destination = app.getByRole("button", { name: "Destination", exact: true });
if (await destination.isVisible()) {
await destination.click();
await page.getByRole("button", { name: recurringAccountLabel }).click();
}

await app.getByRole("button", { name: "Funding wallet", exact: true }).click();
await app.getByRole("button", { name: "Source wallet", exact: true }).click();
await page.getByPlaceholder("Search wallets").fill(recurringWalletLabel);
await page.getByRole("button", { name: recurringWalletLabel }).click();

await app.getByRole("button", { name: "Asset", exact: true }).click();
await app.getByRole("button", { name: "Token", exact: true }).click();
await expect(page.getByRole("button", { name: /^SOL(?:\s|$)/ })).toHaveCount(0);
await page
.getByRole("button", { name: new RegExp(`^${recurringTokenSymbol}( SDP-Minted)?$`) })
.click();

await app.getByLabel("Amount", { exact: true }).fill("7.5");
await app.getByRole("button", { name: "Billing interval", exact: true }).click();
await expect(next).toBeEnabled({ timeout: 120_000 });
await next.click();

// When: every day, starting after activation.
await app.getByRole("button", { name: "Repeats", exact: true }).click();
await page.getByRole("button", { name: "Every day" }).click();
await expect(next).toBeEnabled({ timeout: 120_000 });
await next.click();

await expect(app.getByText("Review recurring payment")).toBeVisible();
await expect(app.getByText(recurringCounterpartyName)).toBeVisible();
await expect(app.getByText(recurringWalletLabel)).toBeVisible();
await expect(app.getByText(`7.5 ${recurringTokenSymbol}`)).toBeVisible();
// Review.
await expect(
app.getByText(`7.50 ${recurringTokenSymbol} to ${recurringCounterpartyName}`)
).toBeVisible();
await expect(app.getByText(`From ${recurringWalletLabel}`)).toBeVisible();
await expect(app.getByText("Every day", { exact: true })).toBeVisible();

const createButton = app.getByRole("button", {
name: "Create recurring payment",
name: "Create the schedule",
exact: true,
});
await expect(createButton).toBeEnabled({ timeout: 120_000 });
Expand All @@ -129,28 +135,36 @@ test.describe
await expect(page.getByText("Pending activation", { exact: true })).toBeVisible();
await expect(page.getByText("Every day", { exact: true }).first()).toBeVisible();

await page.getByRole("link", { name: "Back to recurring payments" }).click();
await page.getByRole("link", { name: "Schedules", exact: true }).click();
await expect(page).toHaveURL(/\/dashboard\/payments\/recurring$/);
const recurringRow = page
.getByRole("button")
.locator("tbody tr")
.filter({ hasText: recurringCounterpartyName })
.first();
await expect(recurringRow).toBeVisible();
await expect(recurringRow).toContainText(recurringWalletLabel);
await expect(recurringRow).toContainText(`7.50 ${recurringTokenSymbol}`);

await recurringRow.getByText(`7.50 ${recurringTokenSymbol}`, { exact: true }).click();
// The row's title, "<amount> to <contact>", is its link.
await recurringRow
.getByRole("link", { name: `7.50 ${recurringTokenSymbol} to ${recurringCounterpartyName}` })
.click();
await expect(page).toHaveURL(
new RegExp(`/dashboard/payments/recurring/${recurringPaymentId}$`)
);
// The header names the schedule by what it pays and to whom.
await expect(
page.locator("main").getByRole("heading", { level: 1, name: "Recurring payment" })
page.locator("main").getByRole("heading", {
level: 1,
name: `7.50 ${recurringTokenSymbol} to ${recurringCounterpartyName}`,
})
).toBeVisible();
await expect(page.getByRole("link", { name: "Back to recurring payments" })).toBeVisible();
await expect(page.getByText("Payment reference", { exact: true })).toBeVisible();
await expect(page.getByText("Billing interval", { exact: true })).toBeVisible();
await expect(page.getByText("Funding wallet", { exact: true })).toBeVisible();
await expect(page.getByText("Receiving wallet", { exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Schedules", exact: true })).toBeVisible();
// The plan reads as labelled lines; the schedule's identifiers sit under Details.
const recordLabels = page.locator("main dt");
for (const label of ["Pays", "To", "From", "Repeats", "Next run", "Schedule ID"]) {
await expect(recordLabels.getByText(label, { exact: true })).toBeVisible();
}
await expect(page.locator("main").getByText("Token mint", { exact: true })).toHaveCount(0);
await expect(page.locator("main").getByText("Plan PDA", { exact: true })).toHaveCount(0);
await expect(page.locator("main").getByText("Subscription PDA", { exact: true })).toHaveCount(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { COUNTERPARTY_ENTITY_TYPES } from "@sdp/types";
import type {
ApiPlaygroundEndpointConfig,
ApiPlaygroundFieldConfig,
} from "@/components/api-playground-shell";
import type { MessageKey, TranslationValues } from "@/i18n/messages";
import { mergeOpenApiPlaygroundEndpoints } from "@/lib/api-playground-openapi-catalog";

export interface CounterpartyPlaygroundView {
id: string;
displayName: string;
}

const exampleCounterpartyId = "cpty_abc123";
const exampleDisplayName = "Acme Corp";

type Translate = (key: MessageKey, values?: TranslationValues) => string;
const counterpartyIdPathLabel = ["{", "counterpartyId", "}"].join("");

function buildCounterpartyIdField(t: Translate): ApiPlaygroundFieldConfig {
return {
key: "counterpartyId",
label: counterpartyIdPathLabel,
placeholder: t("DashboardPayments.counterparty.playgroundCounterpartyIdPlaceholder"),
required: true,
};
}

export function buildCounterpartyPlaygroundEndpointConfigs(
counterparties: CounterpartyPlaygroundView[],
t: Translate
): ApiPlaygroundEndpointConfig[] {
const entityTypeOptions = COUNTERPARTY_ENTITY_TYPES.map((value) => ({
label:
value === "individual"
? t("DashboardPayments.counterparty.individual")
: t("DashboardPayments.counterparty.business"),
value,
}));
const counterpartyOptions = counterparties.map((cp) => ({
value: cp.id,
label: cp.displayName,
}));
const firstCounterpartyOption = counterpartyOptions[0];
const counterpartyIdField: ApiPlaygroundFieldConfig =
firstCounterpartyOption === undefined
? buildCounterpartyIdField(t)
: {
key: "counterpartyId",
label: counterpartyIdPathLabel,
placeholder: t("DashboardPayments.counterparty.playgroundCounterpartyIdPlaceholder"),
kind: "select",
options: counterpartyOptions,
defaultValue: firstCounterpartyOption.value,
required: true,
};

const firstId = counterparties[0]?.id ?? exampleCounterpartyId;
const firstName = counterparties[0]?.displayName ?? exampleDisplayName;

const curatedEndpoints: ApiPlaygroundEndpointConfig[] = [
{
id: "list-counterparties",
title: t("DashboardPayments.counterparty.listCounterparties"),
method: "GET",
path: "/v1/counterparties",
pathFields: [],
bodyFields: [],
expectedResponse: {
counterparties:
counterparties.length > 0
? counterparties.map((cp) => ({ id: cp.id, displayName: cp.displayName }))
: [{ id: exampleCounterpartyId, displayName: exampleDisplayName }],
total: counterparties.length || 1,
page: 1,
pageSize: 100,
},
},
{
id: "get-counterparty",
title: t("DashboardPayments.counterparty.getCounterparty"),
method: "GET",
path: "/v1/counterparties/{counterpartyId}",
pathFields: [counterpartyIdField],
bodyFields: [],
expectedResponse: {
counterparty: {
id: firstId,
displayName: firstName,
entityType: "business",
status: "active",
},
},
},
{
id: "create-counterparty",
title: t("DashboardPayments.counterparty.createCounterparty"),
method: "POST",
path: "/v1/counterparties",
pathFields: [],
bodyFields: [
{
key: "displayName",
label: "displayName",
placeholder: t("DashboardPayments.counterparty.businessNamePlaceholder"),
defaultValue: exampleDisplayName,
required: true,
},
{
key: "entityType",
label: "entityType",
placeholder: t("DashboardPayments.counterparty.selectEntityType"),
kind: "select",
options: entityTypeOptions,
defaultValue: "business",
required: true,
},
{
key: "externalId",
label: "externalId",
placeholder: t("DashboardPayments.newDesign.counterparty.externalIdPlaceholder"),
},
],
expectedResponse: {
counterparty: {
id: exampleCounterpartyId,
displayName: exampleDisplayName,
entityType: "business",
status: "active",
createdAt: new Date().toISOString(),
},
},
},
{
id: "update-counterparty",
title: t("DashboardPayments.counterparty.updateCounterparty"),
method: "PATCH",
path: "/v1/counterparties/{counterpartyId}",
pathFields: [counterpartyIdField],
bodyFields: [
{
key: "displayName",
label: "displayName",
placeholder: t("DashboardPayments.counterparty.updatedDisplayNamePlaceholder"),
},
{
key: "entityType",
label: "entityType",
placeholder: t("DashboardPayments.counterparty.selectEntityType"),
kind: "select",
options: entityTypeOptions,
},
{
key: "externalId",
label: "externalId",
placeholder: t("DashboardPayments.newDesign.counterparty.externalIdPlaceholder"),
},
],
expectedResponse: {
counterparty: {
id: firstId,
displayName: firstName,
entityType: "business",
status: "active",
},
},
},
{
id: "delete-counterparty",
title: t("DashboardPayments.counterparty.deleteCounterparty"),
method: "DELETE",
path: "/v1/counterparties/{counterpartyId}",
pathFields: [counterpartyIdField],
bodyFields: [],
expectedResponse: {
deleted: true,
counterpartyId: firstId,
},
},
];

return mergeOpenApiPlaygroundEndpoints("counterparties", curatedEndpoints);
}
Loading
Loading