diff --git a/.changeset/add-sdk-build-and-release.md b/.changeset/add-sdk-build-and-release.md new file mode 100644 index 0000000..106d35a --- /dev/null +++ b/.changeset/add-sdk-build-and-release.md @@ -0,0 +1,5 @@ +--- +'@accensa/sdk': minor +--- + +Initial published release of `@accensa/sdk` with build pipeline, package metadata, and release process. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..a29b42f --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "accensa/accensa-app" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b66a3..76df78d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build SDK (web resolves @accensa/sdk from dist) + working-directory: ./packages/sdk + run: pnpm build - name: Test web working-directory: ./apps/web env: @@ -110,7 +113,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile # Unit tests only: synthetic and fixture data, no live RPC or database. @@ -136,6 +139,9 @@ jobs: # apps/docs (Docusaurus) uses its own tsconfig via @docusaurus/tsconfig # and is excluded — its types are checked by docusaurus build instead. # apps/demo-merchant is plain JavaScript and has no TypeScript to check. + - name: Build SDK (web typecheck resolves @accensa/sdk from dist) + working-directory: ./packages/sdk + run: pnpm build - name: Typecheck workspace packages run: pnpm typecheck @@ -152,10 +158,73 @@ jobs: run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build SDK (web build resolves @accensa/sdk from dist) + working-directory: ./packages/sdk + run: pnpm build - name: Build Next.js app working-directory: ./apps/web run: pnpm build + verify-pack: + name: verify SDK tarball + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@11 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build SDK + working-directory: ./packages/sdk + run: pnpm build + - name: Pack SDK tarball + working-directory: ./packages/sdk + run: pnpm pack --pack-destination /tmp/sdk-tarball + - name: Verify tarball installs and imports in ESM and CJS + run: | + TARBALL=$(ls /tmp/sdk-tarball/*.tgz) + + # Create scratch project outside the workspace + SCRATCH=$(mktemp -d) + cd "$SCRATCH" + npm init -y > /dev/null 2>&1 + npm install "$TARBALL" > /dev/null 2>&1 + + # Test CJS import + node -e " + const sdk = require('@accensa/sdk'); + if (typeof sdk.verifyReceipt !== 'function') throw new Error('verifyReceipt not exported'); + if (typeof sdk.attachAccensaHook !== 'function') throw new Error('attachAccensaHook not exported'); + if (typeof sdk.createSettleHook !== 'function') throw new Error('createSettleHook not exported'); + console.log('CJS: root entry OK'); + " + + # Test CJS merkle import + node -e " + const merkle = require('@accensa/sdk/merkle'); + if (typeof merkle.verifyReceipt !== 'function') throw new Error('verifyReceipt not exported from /merkle'); + console.log('CJS: merkle entry OK'); + " + + # Test ESM import + cat > "$SCRATCH/test.mjs" << 'EOF' + import { verifyReceipt, attachAccensaHook, createSettleHook } from '@accensa/sdk'; + import { verifyReceipt as vr } from '@accensa/sdk/merkle'; + + if (typeof verifyReceipt !== 'function') throw new Error('verifyReceipt not exported'); + if (typeof attachAccensaHook !== 'function') throw new Error('attachAccensaHook not exported'); + if (typeof createSettleHook !== 'function') throw new Error('createSettleHook not exported'); + if (typeof vr !== 'function') throw new Error('verifyReceipt not exported from /merkle'); + console.log('ESM: both entries OK'); + EOF + node "$SCRATCH/test.mjs" + + echo "All import checks passed" + e2e: name: e2e (web) runs-on: ubuntu-latest @@ -164,9 +233,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Install Playwright browser diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1e6eded --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,122 @@ +name: Publish @accensa/sdk + +on: + push: + tags: + - '@accensa/sdk@*' + +jobs: + build-and-test: + name: build & test SDK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@11 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build SDK + working-directory: ./packages/sdk + run: pnpm build + - name: Test SDK + working-directory: ./packages/sdk + run: pnpm test + - name: Typecheck SDK + working-directory: ./packages/sdk + run: pnpm typecheck + - name: Verify conformance vectors are reproducible + run: | + node packages/sdk/scripts/generate-vectors.mjs + git diff --exit-code -- packages/sdk/merkle-vectors.json packages/sdk/vectors.rs + + verify-pack: + name: verify tarball + needs: build-and-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@11 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build SDK + working-directory: ./packages/sdk + run: pnpm build + - name: Pack SDK tarball + working-directory: ./packages/sdk + run: pnpm pack --pack-destination /tmp/sdk-tarball + - name: Verify tarball installs and imports in ESM and CJS + run: | + TARBALL=$(ls /tmp/sdk-tarball/*.tgz) + + # Create scratch project outside the workspace + SCRATCH=$(mktemp -d) + cd "$SCRATCH" + npm init -y > /dev/null 2>&1 + npm install "$TARBALL" > /dev/null 2>&1 + + # Test CJS import + node -e " + const sdk = require('@accensa/sdk'); + if (typeof sdk.verifyReceipt !== 'function') throw new Error('verifyReceipt not exported'); + if (typeof sdk.attachAccensaHook !== 'function') throw new Error('attachAccensaHook not exported'); + if (typeof sdk.createSettleHook !== 'function') throw new Error('createSettleHook not exported'); + console.log('CJS: root entry OK'); + " + + # Test CJS merkle import + node -e " + const merkle = require('@accensa/sdk/merkle'); + if (typeof merkle.verifyReceipt !== 'function') throw new Error('verifyReceipt not exported from /merkle'); + console.log('CJS: merkle entry OK'); + " + + # Test ESM import + cat > "$SCRATCH/test.mjs" << 'EOF' + import { verifyReceipt, attachAccensaHook, createSettleHook } from '@accensa/sdk'; + import { verifyReceipt as vr } from '@accensa/sdk/merkle'; + + if (typeof verifyReceipt !== 'function') throw new Error('verifyReceipt not exported'); + if (typeof attachAccensaHook !== 'function') throw new Error('attachAccensaHook not exported'); + if (typeof createSettleHook !== 'function') throw new Error('createSettleHook not exported'); + if (typeof vr !== 'function') throw new Error('verifyReceipt not exported from /merkle'); + console.log('ESM: both entries OK'); + EOF + node "$SCRATCH/test.mjs" + + echo "All import checks passed" + + publish: + name: publish to npm + needs: verify-pack + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + - name: Install pnpm + run: npm install -g pnpm@11 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build SDK + working-directory: ./packages/sdk + run: pnpm build + - name: Publish with provenance + working-directory: ./packages/sdk + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml index 1d4ac47..06001c2 100644 --- a/.github/workflows/reconcile.yml +++ b/.github/workflows/reconcile.yml @@ -32,7 +32,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Rebuild from chain and diff against production diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml index 2be60bc..a94d698 100644 --- a/.github/workflows/visual.yml +++ b/.github/workflows/visual.yml @@ -27,7 +27,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 working-directory: . - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e4e418..2790a3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ We welcome contributions from the community! Whether it's a bug fix, new feature 1. **Fork the repository** on GitHub. 2. **Clone your fork** locally. -3. **Find an issue**: Look for issues labeled with `good first issue` if you are a new contributor. If you have an idea for a feature or found a bug, please create a new issue first to discuss it with the maintainers before starting work. +3. **Find an issue**: Look for issues labeled with `good first issue` if you are a new contributor. If you have an idea for a feature or found a bug, please create an issue first to discuss it with the maintainers before starting work. 4. **Wait for assignment**: To avoid duplicate work, please express your interest on the issue and wait for a maintainer to assign it to you before starting work. 5. **Create a new branch** for your feature or bug fix (`git checkout -b feature/my-new-feature` or `bugfix/issue-123`). 6. **Make your changes** and test them thoroughly. @@ -46,4 +46,81 @@ CI will reject unformatted code via `pnpm format:check`. If you find a bug or have a feature idea, please open an issue on GitHub using our issue templates. Include as much detail as possible to help us understand and resolve the issue quickly. -Thank you for helping make Accensa better! +--- + +## `@accensa/sdk` Release Process + +### Semver Policy + +The SDK follows [Semantic Versioning](https://semver.org/). The following are +considered **breaking changes** (major bumps): + +- Changes to `SettleHookPayload` fields (the JSON body POSTed to + `/api/hook/settle`). +- Changes to the Ed25519 signing mechanism or signature encoding. +- Removal or renaming of any exported symbol. +- Changes to the `X-Signature` header contract. + +The report payload and signature scheme are a **wire contract** between the SDK +and the Accensa indexer. A change that the indexer does not also accept is a +breaking change even if the TypeScript types are compatible. + +Non-breaking additions (new optional fields, new exports, bug fixes) are minor +or patch bumps as usual. + +### Changesets + +This monorepo uses [Changesets](https://github.com/changesets/changesets) to +manage versioning and changelogs. + +When you land a change that affects `@accensa/sdk`: + +```bash +pnpm changeset +``` + +Follow the prompts to select the package and bump type, then write a summary +that will appear in the changelog. Commit the generated +`.changeset/*.md` file with your PR. + +### Cutting a Release + +1. **Merge the release PR.** Changesets opens a "Version Packages" PR + automatically when changesets accumulate on `main`. Merge it to bump + `package.json` versions and update `CHANGELOG.md`. + +2. **Tag the release.** After the version PR merges, tag the commit: + + ```bash + git tag @accensa/sdk@ + git push origin @accensa/sdk@ + ``` + +3. **CI publishes on tag.** The `publish` workflow in + `.github/workflows/publish.yml` detects the tag, builds the package, runs + the tarball verification job, and publishes to npm with provenance. + +### Manual Release (if needed) + +If you need to publish manually outside the automated flow: + +```bash +cd packages/sdk +pnpm build +pnpm pack # inspect the tarball +npm publish --provenance --access public +``` + +### Verifying the Package + +Before publishing, verify the packed tarball works in an isolated project: + +```bash +cd packages/sdk +pnpm build +pnpm pack +``` + +Then create a scratch directory outside the workspace, install the tarball, and +confirm both entry points resolve in ESM and CJS. This is also run +automatically in CI (see `.github/workflows/ci.yml`, the `verify-pack` job). diff --git a/README.md b/README.md index d3e41c9..9960aba 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,12 @@ the window the row is `failed` and is listed on the dashboard. **Signature.** Ed25519 over the exact UTF-8 body bytes, the same scheme as settlement reporting. -| Header | Value | -| --- | --- | -| `Content-Type` | `application/json` | -| `X-Signature` | hex-encoded Ed25519 signature of the raw body | -| `X-Accensa-Timestamp` | Unix seconds at sign time | -| `X-Accensa-Delivery-Id` | `webhook_deliveries.id` | +| Header | Value | +| ----------------------- | --------------------------------------------- | +| `Content-Type` | `application/json` | +| `X-Signature` | hex-encoded Ed25519 signature of the raw body | +| `X-Accensa-Timestamp` | Unix seconds at sign time | +| `X-Accensa-Delivery-Id` | `webhook_deliveries.id` | `WEBHOOK_SIGNING_KEY` is a 32-byte Ed25519 private key as hex. Without it, queued deliveries fail closed rather than going out unsigned. Verify with the diff --git a/apps/web/e2e/__screenshots__/dashboard-empty.png b/apps/web/e2e/__screenshots__/dashboard-empty.png new file mode 100644 index 0000000..0b4aaca Binary files /dev/null and b/apps/web/e2e/__screenshots__/dashboard-empty.png differ diff --git a/apps/web/e2e/__screenshots__/navbar.png b/apps/web/e2e/__screenshots__/navbar.png new file mode 100644 index 0000000..fc4893c Binary files /dev/null and b/apps/web/e2e/__screenshots__/navbar.png differ diff --git a/apps/web/e2e/__screenshots__/payments-table.png b/apps/web/e2e/__screenshots__/payments-table.png new file mode 100644 index 0000000..26be547 Binary files /dev/null and b/apps/web/e2e/__screenshots__/payments-table.png differ diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index bbe99a0..fac5530 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -71,9 +71,7 @@ async function runAxe(page: Page, label: string) { for (const v of allowed) { const entry = ALLOWLIST[v.id]; - console.log( - `[allowlist] ${label}: ${v.id} on "${entry.element}" (tracked: ${entry.issue})`, - ); + console.log(`[allowlist] ${label}: ${v.id} on "${entry.element}" (tracked: ${entry.issue})`); } const failures = violations.map((v) => ({ @@ -87,10 +85,7 @@ async function runAxe(page: Page, label: string) { failures, `Accessibility violations on ${label}:\n` + failures - .map( - (f) => - `- ${f.rule} (${f.impact}): ${f.help} on ${f.elements.join(', ')}`, - ) + .map((f) => `- ${f.rule} (${f.impact}): ${f.help} on ${f.elements.join(', ')}`) .join('\n'), ).toEqual([]); } @@ -110,8 +105,8 @@ test.describe('accessibility', () => { await page.goto('/dashboard'); await expect(page.getByRole('heading', { name: 'Settled Volume' })).toBeVisible(); - // Open the first payment row to expose the modal. - const row = page.getByText(new RegExp(MOCK_PAYMENTS[0].tx_hash.slice(0, 16))).first(); + // Open the first payment row (desktop table row) to expose the modal. + const row = page.locator('tr', { hasText: MOCK_PAYMENTS[0].amount }).first(); await row.click(); await expect(page.getByText('Payment Details')).toBeVisible(); @@ -122,19 +117,19 @@ test.describe('accessibility', () => { await mockDashboardApi(page); await mintSession(page); await page.goto('/dashboard/routes'); - await expect(page.getByText('Revenue by Route', { exact: false })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Revenue by Route' })).toBeVisible(); await runAxe(page, '/dashboard/routes'); }); test('/verify', async ({ page }) => { await page.goto('/verify'); - await expect(page.getByText('Verify Receipt', { exact: false })).toBeVisible(); + await expect(page.getByText('Verify a Receipt', { exact: false })).toBeVisible(); await runAxe(page, '/verify'); }); test('/login', async ({ page }) => { await page.goto('/login'); - await expect(page.getByText(/Freighter/)).toBeVisible(); + await expect(page.getByText('Merchant Login')).toBeVisible(); await runAxe(page, '/login'); }); -}); \ No newline at end of file +}); diff --git a/apps/web/e2e/flows/dashboard.spec.ts b/apps/web/e2e/flows/dashboard.spec.ts index 1cd166c..7e37db0 100644 --- a/apps/web/e2e/flows/dashboard.spec.ts +++ b/apps/web/e2e/flows/dashboard.spec.ts @@ -1,6 +1,11 @@ import { test, expect, type Page } from '@playwright/test'; import { mintSessionCookie } from '../helpers/auth'; -import { MOCK_PAYMENTS, mockDashboardApi, mockDashboardApiEmpty, mockDashboardApiError } from '../helpers/mocks'; +import { + MOCK_PAYMENTS, + mockDashboardApi, + mockDashboardApiEmpty, + mockDashboardApiError, +} from '../helpers/mocks'; async function openDashboard(page: Page) { await page.context().addCookies([ @@ -20,10 +25,12 @@ test.describe('dashboard', () => { await expect(page.getByRole('heading', { name: 'Settled Volume' })).toBeVisible(); // Total settled reflects the mocked fixtures (10.00 + 5.50). - await expect(page.getByText("15.50")).toBeVisible(); - // Both payment assets render. - await expect(page.getByText('USDC').first()).toBeVisible(); - await expect(page.getByText('XLM').first()).toBeVisible(); + await expect(page.getByText('15.50')).toBeVisible(); + // Both payment assets render in the desktop table. Scoped to the table so + // we don't match the hidden mobile card-list span. + const table = page.locator('table'); + await expect(table.getByText('USDC')).toBeVisible(); + await expect(table.getByText('XLM')).toBeVisible(); // The table caption / heading is present. await expect(page.getByRole('heading', { name: 'Recent Settlements' })).toBeVisible(); }); @@ -42,7 +49,7 @@ test.describe('dashboard', () => { await expect(page.getByText('Transaction Hash')).toBeVisible(); // Close via the close button. - await page.getByRole('button', { name: 'Close details' }).click(); + await page.getByRole('button', { name: 'Close payment details' }).click(); await expect(modal).not.toBeVisible(); }); @@ -74,7 +81,7 @@ test.describe('dashboard', () => { const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export CSV' }).click(); const download = await downloadPromise; - expect(download.suggestedFilename()).toMatch(/^accensa-payments-.*\.csv$/); + expect(download.suggestedFilename()).toMatch(/^accensa_payments_.*\.csv$/); }); test('sync button enters cooldown after a sync attempt', async ({ page }) => { @@ -87,8 +94,9 @@ test.describe('dashboard', () => { // The mocked /api/sync POST returns 429 with a 60s cooldown. await syncButton.click(); - await expect(page.getByRole('button', { name: /Wait \d+s/ })).toBeVisible(); - await expect(syncButton).toBeDisabled(); + const waitButton = page.getByRole('button', { name: /Wait \d+s/ }); + await expect(waitButton).toBeVisible(); + await expect(waitButton).toBeDisabled(); }); }); @@ -98,7 +106,7 @@ test.describe('dashboard routes', () => { await openDashboard(page); await page.goto('/dashboard/routes'); - await expect(page.getByText('Revenue by Route', { exact: false })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Revenue by Route' })).toBeVisible(); }); }); @@ -106,9 +114,9 @@ test.describe('auth', () => { test('login page renders and requires a wallet', async ({ page }) => { await page.goto('/login'); await expect(page.getByRole('heading', { name: 'Merchant Login' })).toBeVisible(); - // No wallet in the test browser; the sign-in button leads to an error state. - const signIn = page.getByRole('button', { name: /Sign in/i }); - await signIn.click(); + // No wallet in the test browser; connecting leads to an error state. + const connect = page.getByRole('button', { name: 'Connect Wallet' }); + await connect.click(); await expect(page.getByRole('alert')).toBeVisible(); }); }); @@ -120,4 +128,4 @@ test.describe('verify', () => { await expect(page.getByLabel(/Batch/)).toBeVisible(); await expect(page.getByPlaceholder(/c476fc05/)).toBeVisible(); }); -}); \ No newline at end of file +}); diff --git a/apps/web/e2e/helpers/auth.ts b/apps/web/e2e/helpers/auth.ts index 515af71..120cb7b 100644 --- a/apps/web/e2e/helpers/auth.ts +++ b/apps/web/e2e/helpers/auth.ts @@ -1,10 +1,13 @@ import { SignJWT } from 'jose'; /** - * The JWT_SECRET_KEY the web server signs sessions with. Must match the env - * passed to the Playwright webServer (see playwright.e2e.config.ts). + * The JWT_SECRET_KEY the web server signs sessions with. Playwright injects + * this per config: playwright.e2e.config.ts and its workflow pass + * `playwright-e2e-secret-key`, while playwright.config.ts (visual regression) + * passes `visual-regression-test-secret`. Preferring the env var keeps the + * helper consistent with whichever config launched the run. */ -const E2E_JWT_SECRET = 'playwright-e2e-secret-key'; +const E2E_JWT_SECRET = process.env.JWT_SECRET_KEY ?? 'playwright-e2e-secret-key'; const signingKey = new TextEncoder().encode(E2E_JWT_SECRET); @@ -14,7 +17,9 @@ const signingKey = new TextEncoder().encode(E2E_JWT_SECRET); * The dashboard middleware verifies this cookie, so specs can visit * authenticated routes without driving the Freighter sign-in flow. */ -export async function mintSessionCookie(publicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'): Promise { +export async function mintSessionCookie( + publicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', +): Promise { const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); const token = await new SignJWT({ publicKey, expires: expires.toISOString() }) .setProtectedHeader({ alg: 'HS256' }) @@ -22,4 +27,4 @@ export async function mintSessionCookie(publicKey = 'GAAAAAAAAAAAAAAAAAAAAAAAAAA .setExpirationTime('24h') .sign(signingKey); return `accensa_session=${token}; Path=/; HttpOnly; SameSite=Lax`; -} \ No newline at end of file +} diff --git a/apps/web/e2e/helpers/mocks.ts b/apps/web/e2e/helpers/mocks.ts index 171876a..3712aa0 100644 --- a/apps/web/e2e/helpers/mocks.ts +++ b/apps/web/e2e/helpers/mocks.ts @@ -51,9 +51,7 @@ export function paymentsResponse(payments: MockPayment[] = MOCK_PAYMENTS) { return { payments, total_count: payments.length, - total_amount: payments - .reduce((sum, p) => sum + Number.parseFloat(p.amount), 0) - .toFixed(2), + total_amount: payments.reduce((sum, p) => sum + Number.parseFloat(p.amount), 0).toFixed(2), sync: { level: 'live', age: 0, @@ -116,4 +114,4 @@ export async function mockDashboardApiEmpty(page: Page) { body: JSON.stringify(paymentsResponse([])), }); }); -} \ No newline at end of file +} diff --git a/apps/web/e2e/visual.spec.ts b/apps/web/e2e/visual.spec.ts index cefac99..57939d5 100644 --- a/apps/web/e2e/visual.spec.ts +++ b/apps/web/e2e/visual.spec.ts @@ -67,8 +67,11 @@ test('dashboard empty state', async ({ page, context }) => { }); }); await page.goto('/dashboard'); - await expect(page.getByTestId('dashboard-empty')).toBeVisible(); - await expect(page.getByTestId('dashboard-empty')).toHaveScreenshot('dashboard-empty.png'); + await expect(page.getByText('Awaiting Data')).toBeVisible(); + // Screenshot the whole dashboard region, not the bare text element: a + // single text node's width is font-metric dependent and varies across OSes + // (a box dimension mismatch can't be absorbed by pixel-ratio tolerance). + await expect(page.locator('main')).toHaveScreenshot('dashboard-empty.png'); }); test('dashboard payments table', async ({ page, context }) => { @@ -81,6 +84,8 @@ test('dashboard payments table', async ({ page, context }) => { }); }); await page.goto('/dashboard'); - await expect(page.getByTestId('payments-table')).toBeVisible(); - await expect(page.getByTestId('payments-table')).toHaveScreenshot('payments-table.png'); + await expect(page.getByRole('table', { name: 'Recent Settlements' })).toBeVisible(); + await expect(page.getByRole('table', { name: 'Recent Settlements' })).toHaveScreenshot( + 'payments-table.png', + ); }); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index eea1951..21425c4 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,8 +4,10 @@ const nextConfig: NextConfig = { // The Playwright e2e harness (#202) drives the dev server from 127.0.0.1. // Next.js 16's dev-mode CSRF protection rejects requests that carry an // Origin header for a host not on this list, which otherwise 403s every - // `_next/static` chunk in a headless browser. - allowedDevOrigins: ['http://127.0.0.1:3000', 'http://localhost:3000'], + // `_next/static` chunk in a headless browser. Values are hostnames (or + // wildcard hostnames), not full URLs — the request's Origin hostname is + // compared against them directly. + allowedDevOrigins: ['127.0.0.1', 'localhost'], }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 58f8f40..7495488 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,9 +9,13 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots", "e2e": "playwright test --config playwright.e2e.config.ts", "e2e:headed": "playwright test --config playwright.e2e.config.ts --headed", - "e2e:trace": "playwright test --config playwright.e2e.config.ts --trace on" + "e2e:trace": "playwright test --config playwright.e2e.config.ts --trace on", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { "@accensa/sdk": "workspace:^", @@ -42,9 +46,9 @@ "tailwindcss": "^4.3.2", "typescript": "^5.9.3", "vitest": "^2.1.9", - "@playwright/test": "^1.55.0", "storybook": "^8.6.14", "@storybook/nextjs": "^8.6.14", + "@storybook/react": "^8.6.14", "@storybook/addon-essentials": "^8.6.14" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 063ce78..e2747ba 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -13,6 +13,11 @@ const baseURL = `http://127.0.0.1:${PORT}`; */ export default defineConfig({ testDir: './e2e', + // This config drives the visual-regression suite only. The flow and + // accessibility specs are exercised by playwright.e2e.config.ts (they run + // against port 3000 with their own session/network mocking); running them + // here would hit the wrong port and fail. + testMatch: '**/visual.spec.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -22,6 +27,18 @@ export default defineConfig({ trace: 'on-first-retry', colorScheme: 'light', }, + // Platform-independent snapshot paths (no {platform}/{projectName}) so the + // same committed PNGs are compared on every OS CI runs on. A pixel ratio + // tolerance absorbs cross-OS font rasterization differences while the + // screenshots still catch layout regressions. 0.04 comfortably covers the + // Windows-vs-Linux rendering delta (~2% observed for the navbar) with ~2x + // headroom, whereas a real layout regression produces a much larger diff. + snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}', + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.04, + }, + }, projects: [ { name: 'chromium', diff --git a/apps/web/playwright.e2e.config.ts b/apps/web/playwright.e2e.config.ts index 65f611a..10d8e89 100644 --- a/apps/web/playwright.e2e.config.ts +++ b/apps/web/playwright.e2e.config.ts @@ -15,6 +15,10 @@ import { defineConfig, devices } from '@playwright/test'; */ export default defineConfig({ testDir: './e2e', + // The visual-regression suite is owned by playwright.config.ts (port 3100). + // Exclude it here so the e2e/flow/a11y specs run against the port they were + // written for without tripping over committed screenshot comparisons. + testIgnore: '**/visual.spec.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, @@ -25,9 +29,17 @@ export default defineConfig({ trace: 'on-first-retry', screenshot: 'only-on-failure', }, - projects: [ - { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, - ], + // Platform-independent snapshot paths (no {platform}/{projectName}) so the + // same committed PNGs are compared on every OS CI runs on. A small pixel + // ratio tolerance absorbs cross-OS font rasterization differences while the + // screenshots still catch layout regressions. + snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}', + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + }, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { // `next dev` rather than a production build: the app's API routes are // type-checked lazily per request, and the e2e specs intercept every API @@ -45,4 +57,4 @@ export default defineConfig({ DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/accensa_e2e_none', }, }, -}); \ No newline at end of file +}); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index ca4183e..c91757c 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -95,14 +95,22 @@ export async function GET() { status = 'critical'; reasons.push(`cursor lag ${lagLedgers} >= ${LAG_CRITICAL} ledgers`); } else if (lagLedgers !== null && lagLedgers >= LAG_WARN) { - if (status !== 'critical') status = 'warn'; + status = 'warn'; reasons.push(`cursor lag ${lagLedgers} >= ${LAG_WARN} ledgers`); } if (ageMs !== null && ageMs >= NO_SYNC_CRITICAL_MS) { status = 'critical'; reasons.push(`no successful sync in ${Math.round(ageMs / 60000)} min`); } - return { merchantId: r.merchant_id, lastLedger, lastSyncedAt, lagLedgers, ageMs, status, reasons }; + return { + merchantId: r.merchant_id, + lastLedger, + lastSyncedAt, + lagLedgers, + ageMs, + status, + reasons, + }; }); }); diff --git a/apps/web/src/app/api/merchant/profile/route.test.ts b/apps/web/src/app/api/merchant/profile/route.test.ts index 6f41f34..dad6351 100644 --- a/apps/web/src/app/api/merchant/profile/route.test.ts +++ b/apps/web/src/app/api/merchant/profile/route.test.ts @@ -3,6 +3,7 @@ import { GET, PATCH } from './route'; const { MERCHANT, + clientStub, mockWithClient, mockWithMerchantClient, mockGetMerchantFromRequest, @@ -11,11 +12,15 @@ const { mockRevalidateTag, } = vi.hoisted(() => { const merchant = { id: 1, address: 'GABC' }; + // A minimal postgres client: PATCH also records the config change, whose + // INSERT and CREATE TABLE run through client.query. + const clientStub = { query: vi.fn().mockResolvedValue({ rows: [] }) }; return { MERCHANT: merchant, - mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn({})), + clientStub, + mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn(clientStub)), mockWithMerchantClient: vi.fn( - async (_merchantId: number, fn: (client: unknown) => Promise) => fn({}), + async (_merchantId: number, fn: (client: unknown) => Promise) => fn(clientStub), ), mockGetMerchantFromRequest: vi.fn().mockResolvedValue(merchant), mockUpdateMerchantProfile: vi.fn(), @@ -115,7 +120,7 @@ describe('/api/merchant/profile PATCH', () => { expect(data.profile).toEqual(updated); expect(mockWithMerchantClient).toHaveBeenCalledWith(MERCHANT.id, expect.any(Function)); - expect(mockUpdateMerchantProfile).toHaveBeenCalledWith({}, MERCHANT.id, { + expect(mockUpdateMerchantProfile).toHaveBeenCalledWith(clientStub, MERCHANT.id, { webhookUrl: 'https://merchant.example/hook', }); diff --git a/apps/web/src/app/api/payments/route.ts b/apps/web/src/app/api/payments/route.ts index 6049247..29eb92e 100644 --- a/apps/web/src/app/api/payments/route.ts +++ b/apps/web/src/app/api/payments/route.ts @@ -34,16 +34,6 @@ export interface PaymentsResponse { total_pages: number; /** Total count of all settled payments for this merchant. */ total_count?: number; - /** Sum of all settled payment amounts for this merchant. */ - total_amount?: string; - /** Filter metadata returned when filters are applied. */ - filter_info?: { - route?: string; - payer?: string; - asset?: string; - date_from?: string; - date_to?: string; - }; } export async function GET(request: Request) { @@ -66,9 +56,6 @@ export async function GET(request: Request) { limit = parsed; } - // Page-based (offset) pagination, e.g. ?page=2&limit=50. Absent means page 1, - // which keeps every existing no-parameter caller (the routes page, the SDK's - // first page) on exactly the behaviour they had. const pageParam = searchParams.get('page'); let page = 1; if (pageParam !== null) { @@ -79,8 +66,6 @@ export async function GET(request: Request) { page = parsed; } - // Cursor-based (keyset) pagination, used by @accensa/sdk. The two schemes are - // mutually exclusive: a request cannot offset and keyset at the same time. const cursor = searchParams.get('cursor'); let parsedCursor: { ts: string; txHash: string } | null = null; if (cursor) { @@ -103,6 +88,25 @@ export async function GET(request: Request) { } } + // Date range filter (#142): ?from=ISO-8601&to=ISO-8601 + const fromParam = searchParams.get('from'); + const toParam = searchParams.get('to'); + let fromDate: Date | null = null; + let toDate: Date | null = null; + + if (fromParam) { + fromDate = new Date(fromParam); + if (Number.isNaN(fromDate.getTime())) { + return NextResponse.json({ error: 'from must be a valid ISO-8601 date' }, { status: 400 }); + } + } + if (toParam) { + toDate = new Date(toParam); + if (Number.isNaN(toDate.getTime())) { + return NextResponse.json({ error: 'to must be a valid ISO-8601 date' }, { status: 400 }); + } + } + const offset = (page - 1) * limit; try { @@ -116,22 +120,43 @@ export async function GET(request: Request) { async (client) => { await ensureSchema(client); - // Window functions evaluate over the full filtered row set before LIMIT - // and OFFSET are applied, so one query returns both the page and the - // aggregates the dashboard header needs (total count, sum, single-asset - // detection via min = max). - let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method, - COUNT(*) OVER() AS total, - COALESCE(SUM(amount) OVER(), 0) AS total_amount, - CASE WHEN MIN(COALESCE(asset, 'native')) OVER() = - MAX(COALESCE(asset, 'native')) OVER() - THEN MIN(COALESCE(asset, 'native')) OVER() END AS total_asset - FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; - const params: (string | number)[] = [merchant.id]; - if (parsedCursor) { - query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`; - params.push(parsedCursor.ts, parsedCursor.txHash); - } + // Window functions evaluate over the full filtered row set before LIMIT + // and OFFSET are applied, so one query returns both the page and the + // aggregates the dashboard header needs (total count, sum, single-asset + // detection via min = max). + let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method, + COUNT(*) OVER() AS total, + COALESCE(SUM(amount) OVER(), 0) AS total_amount, + CASE WHEN MIN(COALESCE(asset, 'native')) OVER() = + MAX(COALESCE(asset, 'native')) OVER() + THEN MIN(COALESCE(asset, 'native')) OVER() END AS total_asset + FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; + const params: (string | number)[] = [merchant.id]; + + // Apply date range filter (#142) + if (fromDate) { + query += ` AND ts >= $${params.length + 1}`; + params.push(fromDate.toISOString()); + } + if (toDate) { + query += ` AND ts <= $${params.length + 1}`; + params.push(toDate.toISOString()); + } + + if (parsedCursor) { + query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`; + params.push(parsedCursor.ts, parsedCursor.txHash); + } + query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`; + params.push(limit); + + if (!parsedCursor) { + query += ` OFFSET $${params.length + 1}`; + params.push(offset); + } + + const result = await client.query(query, params); + const countRes = await client.query<{ total_count: string; total_amount: string | null }>( `SELECT count(*)::text AS total_count, coalesce(sum(amount), 0)::text AS total_amount FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`, [merchant!.id], @@ -146,25 +171,6 @@ export async function GET(request: Request) { ? String(countRes.rows[0].total_amount) : '0'; - let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; - const params: (string | number)[] = [merchant.id]; - if (parsedCursor) { - query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`; - params.push(parsedCursor.ts, parsedCursor.txHash); - } - - if (!parsedCursor) { - query += ` OFFSET $${params.length + 1}`; - params.push(offset); - } - - const result = await client.query(query, params); - return { rows: result.rows, sync: await getSyncState(client, merchant.id) }; - }); - query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`; - params.push(limit); - - const result = await client.query(query, params); return { rows: result.rows, sync: await getSyncState(client, merchant!.id), @@ -174,10 +180,7 @@ export async function GET(request: Request) { }, ); - // The fake databases in tests do not return the window columns; tolerate - // their absence so aggregate handling is uniform. const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0; - const totalAmount = rows.length > 0 ? String(rows[0].total_amount ?? 0) : '0'; const totalAsset = rows.length > 0 ? (rows[0].total_asset ?? null) : null; const totalPages = total === 0 ? 0 : Math.ceil(total / limit); @@ -206,18 +209,6 @@ export async function GET(request: Request) { total_asset: totalAsset, total_pages: totalPages, total_count: totalCount, - total_amount: totalAmount, - ...(filterRoute || filterPayer || filterAsset || filterDateFrom || filterDateTo - ? { - filter_info: { - route: filterRoute ?? undefined, - payer: filterPayer ?? undefined, - asset: filterAsset ?? undefined, - date_from: filterDateFrom ?? undefined, - date_to: filterDateTo ?? undefined, - }, - } - : {}), }; return NextResponse.json(body, { headers: { diff --git a/apps/web/src/app/api/sync/proofs/route.ts b/apps/web/src/app/api/sync/proofs/route.ts index 796ae4f..79cb7c3 100644 --- a/apps/web/src/app/api/sync/proofs/route.ts +++ b/apps/web/src/app/api/sync/proofs/route.ts @@ -108,9 +108,6 @@ export async function POST(request: Request) { { status: result.recorded ? 201 : 200 }, ); } catch { - return NextResponse.json( - { success: false, error: 'Internal Server Error' }, - { status: 500 }, - ); + return NextResponse.json({ success: false, error: 'Internal Server Error' }, { status: 500 }); } } diff --git a/apps/web/src/app/api/sync/route.ts b/apps/web/src/app/api/sync/route.ts index c54d186..b9e5762 100644 --- a/apps/web/src/app/api/sync/route.ts +++ b/apps/web/src/app/api/sync/route.ts @@ -1,13 +1,13 @@ import { NextResponse } from 'next/server'; -import { decodeTransferEvent, transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events'; +import { transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events'; import { withClient, + withMerchantClient, ensureSchema, getLastSyncedLedger, getSyncState, rollbackSyncToLedger, setLastSyncedLedger, - getSyncState, } from '@/lib/db'; import { sweepLedgerRange, @@ -27,6 +27,9 @@ import { } from '@/lib/insert-payments'; import { listMerchants, getMerchantFromRequest, type Merchant } from '@/lib/merchants'; import { cooldownRemaining } from '@/lib/sync-status'; +import { broadcastSyncEvent, hasSubscribers } from '@/lib/sync-events'; +import { isAuthorizedCronRequest } from '@/lib/cron-auth'; +import { logSyncFailure, notifySyncFailure, type SyncFailureContext } from '@/lib/sync-logger'; import { createHmac } from 'node:crypto'; export const dynamic = 'force-dynamic'; @@ -39,7 +42,7 @@ const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar. * to the testnet native XLM SAC; set ASSET_CONTRACT_IDS to a comma-separated * list to settle in USDC or across multiple assets. */ -const ASSET_CONTRACT_IDS = ( +const DEFAULT_ASSET_CONTRACT_IDS = ( process.env.ASSET_CONTRACT_IDS ?? 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' ) .split(',') @@ -132,18 +135,56 @@ interface CooldownResult { } /** - * Indexes Stellar Asset Contract transfers into the merchant's payment ledger. + * Inserts `rows` in batches inside a single transaction. + * + * Mirrors `insertPaymentsInTransaction`'s batching but without advancing the + * sync cursor: the streaming consumer calls this once per completed ledger + * window, and the route advances the cursor to the sweep's final + * `sweptThrough` afterwards. Each chunk commits atomically with the window — + * if a chunk fails, the ROLLBACK discards the window's writes, and the cursor + * is never moved because it is only written after the sweep. Webhooks are not + * fired here; they run after COMMIT in the caller. + * + * @returns The RETURNING rows — exactly the payments inserted this window + * (conflicts skipped by the `WHERE ledger IS NULL` guard are not returned). + */ +async function insertPaymentRows( + client: import('pg').Client, + merchantId: number, + rows: PaymentRow[], +): Promise[]> { + await client.query('BEGIN'); + try { + const payments: Record[] = []; + for (const chunk of chunkRows(rows, PAYMENTS_BATCH_SIZE)) { + const res = await client.query>( + buildBatchInsertSql(chunk.length), + flattenRows(chunk), + ); + payments.push(...res.rows); + } + await client.query('COMMIT'); + return payments; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; + } +} + +/** + * Indexes Stellar Asset Contract transfers into one merchant's payment ledger. * - * Shared by both entry points: the scheduled GET, and the POST behind the - * dashboard's manual trigger. `cooldownMs`, when set, makes the run a no-op if - * the last sync is more recent than that. + * Shared by both entry points: the scheduled GET (looped over every merchant), + * and the POST behind the dashboard's manual trigger (one merchant, the caller). + * `cooldownMs`, when set, makes the run a no-op if the last sync is more recent + * than that. */ -async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { - return withClient(async (client) => { +async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { + return withMerchantClient(merchant.id, async (client) => { await ensureSchema(client); if (opts.cooldownMs) { - const state = await getSyncState(client); + const state = await getSyncState(client, merchant.id); const retryAfterMs = cooldownRemaining(state?.updatedAt, opts.cooldownMs); if (retryAfterMs > 0) return { cooldown: true, retryAfterMs } as CooldownResult; } @@ -177,6 +218,7 @@ async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { if (startLedger > latestLedger) { return { + merchant: merchant.address, latestLedger, startLedger, syncedTo: startLedger - 1, @@ -198,12 +240,13 @@ async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { // Filter server-side to transfers addressed to this merchant. The asset // topic is optional across protocol versions, so match both arities. - const toTopic = addressTopicFilter(merchant); + const toTopic = addressTopicFilter(merchant.address); const transfer = transferTopicFilter(); + const assetContractIds = merchant.assetContractIds ?? DEFAULT_ASSET_CONTRACT_IDS; const filters = [ { type: 'contract', - contractIds: ASSET_CONTRACT_IDS, + contractIds: assetContractIds, topics: [ [transfer, '*', toTopic, '*'], [transfer, '*', toTopic], @@ -214,99 +257,101 @@ async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { // The limit belongs under `pagination`; sent at the top level the RPC // ignores it and applies its own default. const deadline = Date.now() + PAGING_BUDGET_MS; - const { events, sweptThrough, complete, pages, windows } = await sweepLedgerRange( - ({ startLedger: from, endLedger: to, cursor: pageCursor }) => - rpc('getEvents', { - ...(pageCursor ? {} : { startLedger: from, endLedger: to }), - filters, - pagination: { limit: EVENTS_PAGE_LIMIT, ...(pageCursor ? { cursor: pageCursor } : {}) }, - xdrFormat: 'base64', - }), - { startLedger, endLedger: latestLedger, withinBudget: () => Date.now() < deadline }, - ); + const fetchPage = ({ + startLedger: from, + endLedger: to, + cursor: pageCursor, + }: { + startLedger?: number; + endLedger?: number; + cursor?: string; + }) => + rpc('getEvents', { + ...(pageCursor ? {} : { startLedger: from, endLedger: to }), + filters, + pagination: { + limit: EVENTS_PAGE_LIMIT, + ...(pageCursor ? { cursor: pageCursor } : {}), + }, + xdrFormat: 'base64', + }); + const gap = latestLedger - startLedger + 1; + const sweepFn = gap > PARALLEL_SYNC_THRESHOLD ? parallelSweepLedgerRange : sweepLedgerRange; let inserted = 0; let decoded = 0; - for (const event of events) { - const transferEvent = decodeTransferEvent(event); - // A malformed or non-transfer event must not stall the batch. - if (!transferEvent) continue; - decoded++; - - // Defensive: never record a transfer that is not to this merchant. - if (transferEvent.to !== merchant) continue; - - // DO UPDATE, not DO NOTHING: a row may already exist because the - // merchant reported route attribution before this transfer was - // indexed, which is the normal ordering — the hook fires the moment - // x402 settles, this job runs on a schedule. Skipping the conflict - // would leave that row permanently null and invisible. - // - // Only ledger-owned columns are written. route, method, request_id and - // hook_reported_at belong to the merchant's report and are left alone. - await client.query('BEGIN'); - try { - const res = await client.query( - `INSERT INTO payments (tx_hash, ledger, payer, amount, asset, ts) - VALUES ($1, $2, $3, $4::numeric, $5, $6::timestamptz) - ON CONFLICT (tx_hash) DO UPDATE - SET ledger = EXCLUDED.ledger, - payer = EXCLUDED.payer, - amount = EXCLUDED.amount, - asset = EXCLUDED.asset, - ts = EXCLUDED.ts - WHERE payments.ledger IS NULL RETURNING *`, - [ - merchant.id, - transferEvent.txHash, - transferEvent.ledger, - transferEvent.from, - transferEvent.amount, // string - never a float - transferEvent.asset, - transferEvent.ledgerClosedAt, - ], - ); - if (res.rowCount && res.rowCount > 0 && webhookUrl) { - const payment = res.rows[0]; - const body = JSON.stringify(payment); - const webhookSecret = process.env.WEBHOOK_SECRET; - const headers: Record = { 'Content-Type': 'application/json' }; - if (webhookSecret) { - headers['X-Webhook-Signature'] = createHmac('sha256', webhookSecret) - .update(body) - .digest('hex'); - } - const timeoutMs = 2000; - for (let i = 0; i < 3; i++) { - try { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeoutMs); - const webhookRes = await fetch(webhookUrl, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - clearTimeout(id); - if (webhookRes.ok || webhookRes.status < 500) break; - } catch { - // A webhook the merchant cannot receive must not stall indexing. + // Streams each completed ledger window to an awaited consumer so the + // whole catch-up backlog is never retained in memory. Upserts stay + // sequential and deterministic because onEvents awaits before the sweep + // advances to the next window. + const { sweptThrough, complete, pages, windows, scanned } = await sweepFn(fetchPage, { + startLedger, + endLedger: latestLedger, + withinBudget: () => Date.now() < deadline, + onEvents: async (events: EventPage['events']) => { + const webhookUrl = merchant.webhookUrl ?? process.env.WEBHOOK_URL; + + // Per-event filtering lives in eventsToPaymentRows: a malformed or + // non-transfer event is skipped, and a transfer not addressed to this + // merchant is never recorded. Only the insert below is batched — + // batching must not quietly admit events that would have been filtered + // out. + const { rows, decoded: decodedCount } = eventsToPaymentRows(events, merchant); + decoded += decodedCount; + + if (rows.length === 0) return; + + // Batch-insert the window's rows in one transaction. Since the sweep + // only reports whole completed windows (sweptThrough), the cursor is + // advanced separately below after the sweep resolves — never past a + // window that may have been only partially drained. + const payments = await insertPaymentRows(client, merchant.id, rows); + inserted += payments.length; + + // Webhooks fire after COMMIT, so a slow or failing webhook can neither + // hold the transaction open nor roll back a committed batch. The + // returned rows are exactly the payments written this run. + if (webhookUrl) { + for (const payment of payments) { + const body = JSON.stringify(payment); + const webhookSecret = process.env.WEBHOOK_SECRET; + const headers: Record = { 'Content-Type': 'application/json' }; + if (webhookSecret) { + headers['X-Webhook-Signature'] = createHmac('sha256', webhookSecret) + .update(body) + .digest('hex'); + } + const timeoutMs = 2000; + for (let i = 0; i < 3; i++) { + try { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + const webhookRes = await fetch(webhookUrl, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + clearTimeout(id); + if (webhookRes.ok || webhookRes.status < 500) break; + } catch { + // A webhook the merchant cannot receive must not stall indexing. + } + } } } - await client.query('COMMIT'); - inserted += res.rowCount ?? 0; - } catch (error) { - await client.query('ROLLBACK').catch(() => {}); - throw error; - } - } + }, + }); - // The sweep only ever reports whole windows, so this is safe whether or - // not it reached the head. Crucially it advances across empty windows - // too - a quiet merchant that never moved the cursor is how the indexer - // fell behind the RPC retention window and stopped seeing payments. - await setLastSyncedLedger(client, sweptThrough); + // The sweep only ever advances the cursor across whole completed + // windows, so this is safe whether or not it reached the head. Crucially + // it advances across empty windows too - a quiet merchant that never + // moved the cursor is how the indexer fell behind the RPC retention + // window and stopped seeing payments. Each merchant's cursor advances + // independently, so one merchant with no activity cannot hold back or be + // held back by another's progress. + await setLastSyncedLedger(client, merchant.id, sweptThrough); // Push a real-time update to any subscribed dashboard tab instead of // waiting for the next poll (real-time indexer updates). Skipped when no @@ -324,6 +369,7 @@ async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { } return { + merchant: merchant.address, latestLedger, startLedger, syncedTo: sweptThrough, @@ -331,7 +377,7 @@ async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { drained: complete, pages, windows, - scanned: events.length, + scanned, decoded, inserted, }; @@ -402,8 +448,6 @@ function respond(results: SyncResult[], failures: SyncFailure[] = []) { { status: 429, headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) } }, ); } - return NextResponse.json({ success: true, ...result }); -} const summaries = results.map(summarize); const synced = summaries.filter( @@ -433,15 +477,21 @@ function failed(error: unknown, merchant?: string) { * CRON_SECRET when set - both senders pass it as a bearer token - so the * endpoint cannot be driven by arbitrary callers. No cooldown: a scheduled run * is already rate limited by its schedule. + * + * Sweeps every configured merchant in turn, each with its own cursor - a + * merchant with no activity still has its cursor advanced (see runSync), + * which is precisely the fix for the outage that motivated this workflow's + * checks in the first place. */ export async function GET(request: Request) { const secret = process.env.CRON_SECRET; - if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) { + if (secret && !isAuthorizedCronRequest(request.headers.get('authorization'))) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const bad = configError(); - if (bad) return bad; + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } try { const merchants = await withClient(async (client) => { @@ -478,11 +528,14 @@ export async function GET(request: Request) { /** * Manual entry point, behind the dashboard's"Sync now"button. * - * Protected by session authentication via middleware. MANUAL_COOLDOWN_MS bounds the cost. + * Protected by session authentication via middleware, which resolves to + * exactly the merchant that owns this dashboard session — a signed-in + * merchant can only trigger their own sync. MANUAL_COOLDOWN_MS bounds the cost. */ -export async function POST() { - const bad = configError(); - if (bad) return bad; +export async function POST(request: Request) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } let merchant: Merchant | null = null; try { diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 46c9fc7..aa3291f 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,10 +1,12 @@ 'use client'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { formatAmount, sumAmounts, assetLabel } from '@/lib/money'; import { describeSync, type SyncState } from '@/lib/sync-status'; import { CSV_BOM, paymentsCsvFilename, paymentsToCsv } from '@/lib/payments-csv'; import Link from 'next/link'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import useSWR from 'swr'; import { ArrowUpRight } from 'lucide-react'; import { PageContainer } from '@/components/page-container'; import { RefundPanel } from '@/components/refund-panel'; @@ -28,15 +30,83 @@ interface Payment { type LoadState = | { status: 'loading' } - | { status: 'ready'; payments: Payment[]; fetchedAt: number; sync: SyncState | null } + | { status: 'ready'; payments: Payment[]; sync: SyncState | null } + | { + status: 'ready'; + payments: Payment[]; + fetchedAt: number; + sync: SyncState | null; + totalCount?: number; + totalAmount?: string; + } | { status: 'error'; message: string }; +/** A page of payments as `/api/payments` returns them. */ +interface PaymentsResponse { + payments: Payment[]; + sync: SyncState | null; + /** Total indexed payments for the merchant; server-side aggregate. */ + total?: number; + /** Sum of every payment amount; server-side aggregate. */ + total_amount?: string; + /** Raw asset when every payment is in one asset; server-side aggregate. */ + total_asset?: string | null; + /** ceil(total / limit); absent on older deploys. */ + total_pages?: number; + /** Total count of all settled payments; absent on older deploys. */ + total_count?: number; +} + +/** Chunk size for the transaction history. */ +const PAGE_SIZE = 50; const POLL_INTERVAL_MS = 15_000; +const explorerUrl = (hash: string) => `https://stellar.expert/explorer/testnet/tx/${hash}`; + +const paymentsUrl = (page: number) => `/api/payments?limit=${PAGE_SIZE}&page=${page}`; + +async function fetchPaymentsPage(url: string): Promise { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? `Error ${res.status}`); + return res.json(); +} + +/** + * Merges the polled head page with any older pages the merchant has scrolled + * in, newest first, de-duplicated by `tx_hash`. + * + * The head is re-fetched every poll and a new settlement lands at its top, so + * an already-loaded older page can overlap it after a sync; the dedupe keeps + * that from showing a row twice. The head wins on a duplicate, so the freshest + * row data is shown. + */ +export function mergePayments(head: Payment[], older: Payment[]): Payment[] { + const seen = new Set(); + const out: Payment[] = []; + for (const payment of [...head, ...older]) { + if (seen.has(payment.tx_hash)) continue; + seen.add(payment.tx_hash); + out.push(payment); + } + return out; +} function truncate(value: string, head = 8, tail = 6) { return value.length <= head + tail + 1 ? value : `${value.slice(0, head)}…${value.slice(-tail)}`; } +/** Shared handler for Enter/Space activation on a payment row or card. */ +function handleActivationKeyDown(e: React.KeyboardEvent, onSelect: () => void) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } +} + +/** Accessible label identifying a payment by its truncated tx hash and amount. */ +function paymentLabel(p: Payment) { + return `Payment ${formatAmount(p.amount)} ${assetLabel(p.asset)} (${truncate(p.tx_hash)}), view details`; +} + const REFUNDED_STORAGE_KEY = 'accensa-refunded-txs'; function loadRefundedFromStorage(): ReadonlySet { @@ -60,10 +130,9 @@ function saveRefundedToStorage(refunded: ReadonlySet): void { } } -export default function Dashboard() { - const [state, setState] = useState({ status: 'loading' }); +export function Dashboard() { const [selected, setSelected] = useState(null); - const [reloadToken, setReloadToken] = useState(0); + const closeButtonRef = useRef(null); // Refunds issued in this session. The indexer does not watch RefundVault // events yet, so a refund is otherwise invisible until someone opens the // payment again and the contract is re-read. @@ -76,13 +145,14 @@ export default function Dashboard() { const [role, setRole] = useState(null); const [refunded, setRefunded] = useState>(() => new Set()); const markRefunded = useCallback( - (txHash: string) => setRefunded((prev) => new Set(prev).add(txHash)), + (txHash: string) => + setRefunded((prev) => { + const next = new Set(prev).add(txHash); + saveRefundedToStorage(next); + return next; + }), [], ); - // Stable identity: PaymentModal's focus-management effect depends on it, and a - // fresh closure every render (the dashboard re-renders on every 15s poll) - // would re-trap focus mid-interaction. - const closeModal = useCallback(() => setSelected(null), []); const online = useOnline(); const visible = useVisibility(); @@ -148,49 +218,77 @@ export default function Dashboard() { ? { status: 'loading' } : { status: 'ready', payments: data.payments, sync: data.sync ?? null }; - const reload = useCallback(() => setReloadToken((n) => n + 1), []); + // The current page lives in the URL (?page=2) so it survives reloads and can + // be linked to; searchParams is the single source of truth, and `goToPage` + // writes a new URL that the router re-renders this component with. + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + const pageParam = Number(searchParams.get('page') ?? '1'); + const page = Number.isInteger(pageParam) && pageParam >= 1 ? pageParam : 1; + + const goToPage = useCallback( + (next: number) => { + const params = new URLSearchParams(searchParams.toString()); + if (next <= 1) params.delete('page'); + else params.set('page', String(next)); + router.replace(`${pathname}${params.toString() ? `?${params.toString()}` : ''}`, { + scroll: false, + }); + }, + [router, pathname, searchParams], + ); + + // SWR caches each ?page=N response keyed by URL, so paging back to a visited + // page is instant. The 15s poll keeps only the visible page fresh, and the + // `online` gate means a disconnected browser stops requesting (every request + // would fail and replace a good table with an error); reconnecting turns the + // key back on, which refetches immediately rather than waiting out a tick. + const { data, error, mutate } = useSWR( + online ? paymentsUrl(page) : null, + fetchPaymentsPage, + { refreshInterval: POLL_INTERVAL_MS, keepPreviousData: true }, + ); + + // Refresh on demand (retry, or after a manual sync). + const reload = useCallback(() => { + void mutate(); + }, [mutate]); + + const state: LoadState = error + ? { status: 'error', message: describeFailure(error, navigator.onLine) } + : !data + ? { status: 'loading' } + : { status: 'ready', payments: data.payments, sync: data.sync ?? null }; + + const payments: Payment[] = data?.payments ?? []; + const totalPages = data?.total_pages ?? 0; + + // Prefetch the next page into SWR's cache as soon as this one is known, so + // clicking Next is instant. Renders nothing; the cache is the whole point. + const hasNext = totalPages > page; + useSWR(hasNext ? paymentsUrl(page + 1) : null, fetchPaymentsPage); - // `online` is a dependency, not just a guard: polling stops while the browser - // has no connection - every request would fail and overwrite a good table with - // an error - and reconnecting re-runs the effect, which refetches immediately - // rather than waiting out the remainder of a 15s tick. useEffect(() => { - if (!online) return; - const controller = new AbortController(); - async function fetchPayments() { - try { - const res = await fetch('/api/payments', { signal: controller.signal, cache: 'no-store' }); - if (!res.ok) - throw new Error((await res.json().catch(() => ({}))).error ?? `Error ${res.status}`); - const data = await res.json(); - // Tolerate both shapes: the endpoint used to return a bare array, and - // a deploy can briefly serve an older build to an already-open tab. - const payments: Payment[] = Array.isArray(data) ? data : (data.payments ?? []); - const sync: SyncState | null = Array.isArray(data) ? null : (data.sync ?? null); - if (!controller.signal.aborted) { - setState({ status: 'ready', payments, fetchedAt: Date.now(), sync }); - } - } catch (error) { - // Re-read navigator.onLine here rather than closing over `online`: the - // connection can drop between the request going out and it failing, - // and that is exactly the case worth naming correctly. - if (!controller.signal.aborted && !isAbortError(error)) { - setState({ status: 'error', message: describeFailure(error, navigator.onLine) }); - } - } - } - void fetchPayments(); - const timer = setInterval(fetchPayments, POLL_INTERVAL_MS); - return () => { - controller.abort(); - clearInterval(timer); - }; - }, [reloadToken, online]); + if (!selected) return; + closeButtonRef.current?.focus(); + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setSelected(null); + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [selected]); - const payments = state.status === 'ready' ? state.payments : []; - const total = sumAmounts(payments.map((p) => p.amount)); - const assets = new Set(payments.map((p) => assetLabel(p.asset))); - const totalAsset = assets.size === 1 ? [...assets][0] : ''; + // The header total covers every payment, not just the visible page, so the + // number does not shrink when the merchant pages forward. Older deploys that + // lack the server-side aggregates fall back to summing what is on screen. + let total = sumAmounts(payments.map((p) => p.amount)); + let totalAsset = ''; + const pageAssets = new Set(payments.map((p) => assetLabel(p.asset))); + if (pageAssets.size === 1) totalAsset = [...pageAssets][0]; + if (data?.total_amount != null) { + total = data.total_amount; + totalAsset = data.total_asset ? assetLabel(data.total_asset) : ''; + } + const totalCount = data?.total_count ?? payments.length; return (
@@ -199,7 +297,7 @@ export default function Dashboard() {
-

+

Dashboard

@@ -207,26 +305,26 @@ export default function Dashboard() {

Revenue by route →
-
+
- + Total Settled {state.status === 'loading' ? ( - + ) : ( <> {formatAmount(total)} {totalAsset && ( - + {totalAsset} )} @@ -237,14 +335,23 @@ export default function Dashboard() {
{/* Data Table Section */} -
-
-

- Recent Settlements -

+
+
+
+

+ Recent Settlements +

+ {state.status === 'ready' && totalCount > 0 && ( +

+ {totalCount > payments.length + ? `Showing newest ${payments.length} of ${totalCount} payments` + : `Showing all ${payments.length} payment${payments.length === 1 ? '' : 's'}`} +

+ )} +
- +
@@ -258,21 +365,34 @@ export default function Dashboard() { ✕

- Connection Error + {state.message.toLowerCase().includes('session expired') || + state.message.toLowerCase().includes('unauthorized') + ? 'Session Expired' + : 'Connection Error'}

{state.message}

- + {state.message.toLowerCase().includes('session expired') || + state.message.toLowerCase().includes('unauthorized') ? ( + + Sign In Again + + ) : ( + + )} )} - {state.status === 'ready' && payments.length === 0 && ( + {state.status === 'ready' && payments.length === 0 && (data?.total ?? 0) === 0 && (
● @@ -286,70 +406,28 @@ export default function Dashboard() {
)} + {state.status === 'ready' && payments.length === 0 && (data?.total ?? 0) > 0 && ( +
+

+ No payments on this page +

+

+ The list has {(data?.total ?? 0).toLocaleString()} payments. Jump back to the + first page to see the newest ones. +

+ +
+ )} + {state.status === 'ready' && payments.length > 0 && ( <> {/* Mobile View */} -
- {payments.map((payment) => ( -
setSelected(payment)} - className="p-6 hover:bg-slate-50 dark:hover:bg-white/[0.04] transition-colors cursor-pointer group flex flex-col gap-4" - > -
-
- - {formatAmount(payment.amount)} - - - {assetLabel(payment.asset)} - -
-
- {new Date(payment.ts).toLocaleString()} -
-
- -
-
-

- Transaction -

-

- {truncate(payment.tx_hash)} -

-
-
-

- Payer -

-

- {truncate(payment.payer, 4, 4)} -

-
-
-

- Route -

- {payment.route ? ( -
- {payment.method && ( - - {payment.method} - - )} - - {payment.route} - -
- ) : ( - - - )} -
-
-
- ))} -
+ {/* Desktop View */}
@@ -358,6 +436,12 @@ export default function Dashboard() { )}
+ + {state.status === 'ready' && totalPages > 1 && ( +
+ +
+ )}
@@ -365,7 +449,7 @@ export default function Dashboard() { {selected && ( setSelected(null)} refunded={refunded} onRefunded={markRefunded} canRefund={canRefund} @@ -434,7 +518,7 @@ export function PaymentModal({ return (
e.stopPropagation()} >
@@ -456,7 +540,7 @@ export function PaymentModal({ {refunded.has(selected.tx_hash) && ( Refunded @@ -467,7 +551,7 @@ export function PaymentModal({ type="button" onClick={onClose} aria-label="Close payment details" - className="text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-white transition-colors" + className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-white transition-colors cursor-pointer" > ✕ @@ -477,7 +561,7 @@ export function PaymentModal({ label="Transaction Hash" action={} > -
+
{selected.tx_hash}
@@ -485,31 +569,35 @@ export function PaymentModal({ {formatAmount(selected.amount)}{' '} - + {assetLabel(selected.asset)} - + {selected.ledger ?? '-'}
}> -
+
{selected.payer}
- - {new Date(selected.ts).toLocaleString()} - +
)}
-

+

Refund

@@ -538,6 +626,84 @@ export function PaymentModal({ ); } +export function PaymentsCardList({ + payments, + onSelect, +}: { + payments: Payment[]; + onSelect: (payment: Payment) => void; +}) { + return ( +
+ {payments.map((payment) => ( +
onSelect(payment)} + onKeyDown={(e) => handleActivationKeyDown(e, () => onSelect(payment))} + className="p-6 hover:bg-slate-50 dark:hover:bg-white/[0.04] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 transition-colors cursor-pointer group flex flex-col gap-4" + > +
+
+ + {formatAmount(payment.amount)} + + + {assetLabel(payment.asset)} + +
+
+ +
+
+ +
+
+

+ Transaction +

+

+ {truncate(payment.tx_hash)} +

+
+
+

+ Payer +

+

+ {truncate(payment.payer, 4, 4)} +

+
+
+

+ Route +

+ {payment.route ? ( +
+ {payment.method && ( + + {payment.method} + + )} + + {payment.route} + +
+ ) : ( + - + )} +
+
+
+ ))} +
+ ); +} + export function PaymentsTable({ payments, refunded, @@ -551,7 +717,7 @@ export function PaymentsTable({ - + @@ -573,14 +739,18 @@ export function PaymentsTable({ {payments.map((payment) => ( onSelect(payment)} - className="hover:bg-slate-50 dark:hover:bg-white/[0.04] transition-colors cursor-pointer group" + onKeyDown={(e) => handleActivationKeyDown(e, () => onSelect(payment))} + className="hover:bg-slate-50 dark:hover:bg-white/[0.04] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400 transition-colors cursor-pointer group" > - - - ))} @@ -636,7 +808,7 @@ function Field({ return (
- + {label} {action} @@ -649,36 +821,53 @@ function Field({ function StatusPill({ state, onRetry }: { state: LoadState; onRetry: () => void }) { if (state.status === 'loading') return ( - + Syncing... ); - if (state.status === 'error') + if (state.status === 'error') { + const isAuth = + state.message.toLowerCase().includes('session expired') || + state.message.toLowerCase().includes('unauthorized'); + if (isAuth) { + return ( + + Sign In Required + + ); + } return ( ); + // Deliberately reports the indexer's timestamp, not when the poll last + // succeeded. The poll succeeding says nothing about how current the data + // behind it is, and the sync job lands every 1-3 hours in practice. + } // Deliberately reports the indexer's timestamp, not state.fetchedAt. The poll // succeeding says nothing about how current the data behind it is, and the // sync job lands every 1-3 hours in practice. const { level, age, detail } = describeSync(state.sync); const tone = { - live: 'text-emerald-600 dark:text-emerald-400', - lagging: 'text-amber-600 dark:text-amber-400', - stale: 'text-red-600 dark:text-red-400', - unknown: 'text-slate-500 dark:text-slate-400', + live: 'text-emerald-700 dark:text-emerald-400', + lagging: 'text-amber-700 dark:text-amber-400', + stale: 'text-red-700 dark:text-red-400', + unknown: 'text-slate-600 dark:text-slate-400', }[level]; const dot = { - live: 'bg-emerald-500', - lagging: 'bg-amber-500', - stale: 'bg-red-500', - unknown: 'bg-slate-400', + live: 'bg-emerald-600 dark:bg-emerald-500', + lagging: 'bg-amber-600 dark:bg-amber-500', + stale: 'bg-red-600 dark:bg-red-500', + unknown: 'bg-slate-500 dark:bg-slate-400', }[level]; const label = { @@ -696,7 +885,7 @@ function StatusPill({ state, onRetry }: { state: LoadState; onRetry: () => void {/* The ping animation claims activity; only show it when that is true. */} {level === 'live' && ( - + )} @@ -810,8 +999,8 @@ function SyncNowButton({ onSynced }: { onSynced: () => void }) { } className={`px-3 py-2 text-[10px] font-bold uppercase tracking-widest border transition-colors cursor-pointer disabled:cursor-not-allowed ${ state.phase === 'error' - ? 'border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10' - : 'border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 disabled:opacity-50 disabled:hover:bg-transparent' + ? 'border-red-300 dark:border-red-500/20 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10' + : 'border-slate-300 dark:border-white/10 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 disabled:opacity-50 disabled:hover:bg-transparent' }`} > {label} @@ -822,14 +1011,13 @@ function SyncNowButton({ onSynced }: { onSynced: () => void }) { /** * Downloads the payment history the table is showing as a CSV file. * - * Exports what is on screen rather than re-fetching: /api/payments is already - * the whole set the dashboard has (newest 100), and re-requesting would mean - * the file could disagree with the rows the merchant was looking at. + * Exports what is on screen rather than re-fetching every page: the file then + * always matches the rows the merchant was looking at, whatever page that is. * * Serialization lives in lib/payments-csv so it can be tested without a DOM; * this only turns the text into a download. */ -function ExportCsvButton({ payments }: { payments: Payment[] }) { +function ExportCsvButton({ payments, totalCount }: { payments: Payment[]; totalCount?: number }) { const [error, setError] = useState(null); const download = useCallback(() => { @@ -853,6 +1041,8 @@ function ExportCsvButton({ payments }: { payments: Payment[] }) { }, [payments]); const empty = payments.length === 0; + const count = totalCount ?? payments.length; + const isTruncated = count > payments.length; return (
Recent Settlements
Transaction
+ {truncate(payment.tx_hash)} {refunded.has(payment.tx_hash) && ( Refunded @@ -591,31 +761,33 @@ export function PaymentsTable({ {formatAmount(payment.amount)} - + {assetLabel(payment.asset)} + {truncate(payment.payer, 4, 4)} {payment.route ? ( -
+
{payment.method && ( - + {payment.method} )} - + {payment.route}
) : ( - - + - )}
- {new Date(payment.ts).toLocaleString()} + +
+ + + + + + + + + + + {[...Array(5)].map((_, i) => ( + + + + + + + + ))} + +
+ Transaction + + Amount + + Payer + + Route + + Time +
+
+
+
+
+
+
+
+
+
+
+
+ + ); +} + +/** + * The page reads the current page from the URL (?page=2) via useSearchParams, + * which must sit inside a Suspense boundary during prerendering — otherwise the + * production build fails. The dashboard itself is wrapped below; the modal and + * table exports stay named so tests can import them directly. + */ +export default function DashboardPage() { + return ( + + + ); } diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index 6c261e2..521a1d6 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,4 +1,4 @@ -import Link from "next/link"; +import Link from 'next/link'; export default function NotFound() { return ( diff --git a/apps/web/src/components/anchor-panel.tsx b/apps/web/src/components/anchor-panel.tsx index 8b91c13..2a1f704 100644 --- a/apps/web/src/components/anchor-panel.tsx +++ b/apps/web/src/components/anchor-panel.tsx @@ -60,99 +60,101 @@ export function AnchorPanel() { } }, []); - const confirm = useCallback(async (preview: Exclude) => { - setError(null); + const record = useCallback( + async (preview: Exclude, batchId: number, hash: string) => { + try { + const res = await fetch('/api/anchor/record', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + selectionHash: preview.selectionHash, + root: preview.root, + batchId, + anchorTx: hash, + }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error ?? `Recording failed (${res.status})`); + setPhase({ kind: 'done', batchId, hash }); + } catch (e) { + setError( + `${e instanceof Error ? e.message : 'Recording failed'}. The batch is on-chain as #${batchId}; retry recording without submitting again.`, + ); + setPhase({ kind: 'preview', preview }); + } + }, + [], + ); - if ( - preview.existing && - preview.existing.status === 'recorded' && - preview.existing.batchId > 0 - ) { - setPhase({ - kind: 'done', - batchId: preview.existing.batchId, - hash: preview.existing.anchorTx ?? '', - }); - return; - } + const confirm = useCallback( + async (preview: Exclude) => { + setError(null); - if ( - preview.existing && - preview.existing.status === 'submitted' && - preview.existing.batchId > 0 - ) { - setPhase({ - kind: 'recording', - preview, - batchId: preview.existing.batchId, - hash: preview.existing.anchorTx ?? '', - }); - await record(preview, preview.existing.batchId, preview.existing.anchorTx ?? ''); - return; - } + if ( + preview.existing && + preview.existing.status === 'recorded' && + preview.existing.batchId > 0 + ) { + setPhase({ + kind: 'done', + batchId: preview.existing.batchId, + hash: preview.existing.anchorTx ?? '', + }); + return; + } - const wallet = await readStatus(); - if (wallet.kind === 'unavailable') { - setError('Freighter is not installed. Install it, then come back to sign.'); - return; - } - if (wallet.kind !== 'connected') { - const connected = await connect(); - if (connected.kind !== 'connected') { - setError('Freighter did not approve this site. Nothing was submitted.'); + if ( + preview.existing && + preview.existing.status === 'submitted' && + preview.existing.batchId > 0 + ) { + setPhase({ + kind: 'recording', + preview, + batchId: preview.existing.batchId, + hash: preview.existing.anchorTx ?? '', + }); + await record(preview, preview.existing.batchId, preview.existing.anchorTx ?? ''); return; } - } - setPhase({ kind: 'signing', preview }); - const outcome: AnchorOutcome = await submitAnchor({ - root: preview.root, - count: preview.count, - periodStart: preview.periodStart, - periodEnd: preview.periodEnd, - merchant: preview.merchant, - }); + const wallet = await readStatus(); + if (wallet.kind === 'unavailable') { + setError('Freighter is not installed. Install it, then come back to sign.'); + return; + } + if (wallet.kind !== 'connected') { + const connected = await connect(); + if (connected.kind !== 'connected') { + setError('Freighter did not approve this site. Nothing was submitted.'); + return; + } + } - if (outcome.status === 'failed') { - setPhase({ kind: 'preview', preview }); - setError(outcome.message); - return; - } - if (outcome.status === 'pending') { - setPhase({ kind: 'pending', hash: outcome.hash }); - return; - } + setPhase({ kind: 'signing', preview }); + const outcome: AnchorOutcome = await submitAnchor({ + root: preview.root, + count: preview.count, + periodStart: preview.periodStart, + periodEnd: preview.periodEnd, + merchant: preview.merchant, + }); - setPhase({ kind: 'recording', preview, batchId: outcome.batchId, hash: outcome.hash }); - await record(preview, outcome.batchId, outcome.hash); - }, []); + if (outcome.status === 'failed') { + setPhase({ kind: 'preview', preview }); + setError(outcome.message); + return; + } + if (outcome.status === 'pending') { + setPhase({ kind: 'pending', hash: outcome.hash }); + return; + } - const record = async ( - preview: Exclude, - batchId: number, - hash: string, - ) => { - try { - const res = await fetch('/api/anchor/record', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - selectionHash: preview.selectionHash, - root: preview.root, - batchId, - anchorTx: hash, - }), - }); - const body = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(body.error ?? `Recording failed (${res.status})`); - setPhase({ kind: 'done', batchId, hash }); - } catch (e) { - setError( - `${e instanceof Error ? e.message : 'Recording failed'}. The batch is on-chain as #${batchId}; retry recording without submitting again.`, - ); - setPhase({ kind: 'preview', preview }); - } - }; + setPhase({ kind: 'recording', preview, batchId: outcome.batchId, hash: outcome.hash }); + await record(preview, outcome.batchId, outcome.hash); + }, + [record], + ); return (
diff --git a/apps/web/src/components/badge.stories.tsx b/apps/web/src/components/badge.stories.tsx index 39bec11..9fb4a09 100644 --- a/apps/web/src/components/badge.stories.tsx +++ b/apps/web/src/components/badge.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs'; +import type { Meta, StoryObj } from '@storybook/react'; import { Badge } from './badge'; const meta = { diff --git a/apps/web/src/components/cta-button.stories.tsx b/apps/web/src/components/cta-button.stories.tsx index ea2098f..a1b7038 100644 --- a/apps/web/src/components/cta-button.stories.tsx +++ b/apps/web/src/components/cta-button.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs'; +import type { Meta, StoryObj } from '@storybook/react'; import { CtaButton } from './cta-button'; const meta = { diff --git a/apps/web/src/components/data-table.stories.tsx b/apps/web/src/components/data-table.stories.tsx index 2f17c6f..b87913d 100644 --- a/apps/web/src/components/data-table.stories.tsx +++ b/apps/web/src/components/data-table.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/nextjs'; +import type { Meta, StoryObj } from '@storybook/react'; import { DataTable } from './data-table'; import { Badge } from './badge'; diff --git a/apps/web/src/components/webhooks/WebhookManager.tsx b/apps/web/src/components/webhooks/WebhookManager.tsx index b24e083..ae84da8 100644 --- a/apps/web/src/components/webhooks/WebhookManager.tsx +++ b/apps/web/src/components/webhooks/WebhookManager.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client'; -import { useState, useEffect } from "react"; +import { useState, useEffect } from 'react'; /** * Webhook Management UI (#147). @@ -22,8 +22,8 @@ export default function WebhookManager() { const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); - const [newUrl, setNewUrl] = useState(""); - const [newEvents, setNewEvents] = useState("payment.completed,payment.failed"); + const [newUrl, setNewUrl] = useState(''); + const [newEvents, setNewEvents] = useState('payment.completed,payment.failed'); useEffect(() => { fetchWebhooks(); @@ -31,7 +31,7 @@ export default function WebhookManager() { async function fetchWebhooks() { try { - const res = await fetch("/api/webhooks"); + const res = await fetch('/api/webhooks'); if (res.ok) { const data = await res.json(); setWebhooks(data.webhooks ?? []); @@ -46,16 +46,16 @@ export default function WebhookManager() { async function createWebhook() { if (!newUrl) return; try { - const res = await fetch("/api/webhooks", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await fetch('/api/webhooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: newUrl, - events: newEvents.split(",").map((e) => e.trim()), + events: newEvents.split(',').map((e) => e.trim()), }), }); if (res.ok) { - setNewUrl(""); + setNewUrl(''); setShowCreate(false); fetchWebhooks(); } @@ -66,7 +66,7 @@ export default function WebhookManager() { async function deleteWebhook(id: string) { try { - await fetch(`/api/webhooks/${id}`, { method: "DELETE" }); + await fetch(`/api/webhooks/${id}`, { method: 'DELETE' }); fetchWebhooks(); } catch { // Silent fail @@ -76,8 +76,8 @@ export default function WebhookManager() { async function toggleWebhook(id: string, active: boolean) { try { await fetch(`/api/webhooks/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active: !active }), }); fetchWebhooks(); @@ -88,10 +88,10 @@ export default function WebhookManager() { async function testWebhook(id: string) { try { - await fetch(`/api/webhooks/${id}/test`, { method: "POST" }); - alert("Test webhook sent!"); + await fetch(`/api/webhooks/${id}/test`, { method: 'POST' }); + alert('Test webhook sent!'); } catch { - alert("Failed to send test webhook"); + alert('Failed to send test webhook'); } } @@ -107,7 +107,7 @@ export default function WebhookManager() { onClick={() => setShowCreate(!showCreate)} className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm" > - {showCreate ? "Cancel" : "Add Webhook"} + {showCreate ? 'Cancel' : 'Add Webhook'}
@@ -151,9 +151,7 @@ export default function WebhookManager() {

{wh.url}

-

- Events: {wh.events.join(", ")} -

+

Events: {wh.events.join(', ')}

{wh.lastTriggeredAt && (

Last triggered: {new Date(wh.lastTriggeredAt).toLocaleString()} @@ -169,9 +167,9 @@ export default function WebhookManager() {