Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions backend/src/credits/credits-admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,12 +522,29 @@ export class CreditsAdminController {
required: false,
enum: SettlementBatchStatus,
})
@ApiQuery({
name: 'limit',
required: false,
type: Number,
description: 'Page size (1–200)',
})
@ApiQuery({
name: 'cursor',
required: false,
type: String,
description: 'Opaque cursor from a previous page',
})
@ApiResponse({ status: 200, type: [SettlementBatchResponseDto] })
async listBatches(
@Query('status') status?: SettlementBatchStatus,
): Promise<SettlementBatchResponseDto[]> {
const batches = await this.settlement.listBatches(status);
return batches.map((batch) => SettlementBatchResponseDto.fromEntity(batch));
@Query('limit') limit?: string,
@Query('cursor') cursor?: string,
): Promise<{ data: SettlementBatchResponseDto[]; nextCursor: string | null }> {
const page = await this.settlement.listBatches(status, Number(limit) || 50, cursor);
return {
data: page.batches.map((batch) => SettlementBatchResponseDto.fromEntity(batch)),
nextCursor: page.nextCursor,
};
}

@Get('settlement/batches/:id')
Expand Down
60 changes: 54 additions & 6 deletions backend/src/credits/settlement.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
Expand Down Expand Up @@ -66,6 +67,29 @@ export interface SettlementRunSummary {
notes: string[];
}

function encodeSettlementCursor(batch: SettlementBatch): string {
return Buffer.from(
`${batch.createdAt.toISOString()}|${batch.id}`,
'utf8',
).toString('base64url');
}

function decodeSettlementCursor(cursor: string): {
createdAt: Date;
id: string;
} {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
const separator = decoded.lastIndexOf('|');
const rawCreatedAt = separator === -1 ? '' : decoded.slice(0, separator);
const id = separator === -1 ? '' : decoded.slice(separator + 1);
const createdAt = new Date(rawCreatedAt);

if (!id || Number.isNaN(createdAt.getTime())) {
throw new BadRequestException('cursor is not a valid pagination cursor');
}
return { createdAt, id };
}

export interface SettlementBatchBreakdown {
batch: SettlementBatch;
payouts: SettlementPayout[];
Expand Down Expand Up @@ -627,12 +651,36 @@ export class SettlementService implements OnModuleInit {

async listBatches(
status?: SettlementBatchStatus,
): Promise<SettlementBatch[]> {
return this.batchRepository.find({
where: status ? { status } : {},
order: { createdAt: 'DESC' },
take: 100,
});
limit = 50,
cursor?: string,
): Promise<{ batches: SettlementBatch[]; nextCursor: string | null }> {
const pageLimit = Math.min(Math.max(1, Number(limit) || 50), 200);
const query = this.batchRepository
.createQueryBuilder('batch')
.orderBy('batch.createdAt', 'DESC')
.addOrderBy('batch.id', 'DESC')
.take(pageLimit + 1);

if (status) {
query.where('batch.status = :status', { status });
}

if (cursor) {
const { createdAt, id } = decodeSettlementCursor(cursor);
query.andWhere(
'(batch.createdAt < :createdAt OR (batch.createdAt = :createdAt AND batch.id < :id))',
{ createdAt, id },
);
}

const rows = await query.getMany();
const hasMore = rows.length > pageLimit;
const batches = hasMore ? rows.slice(0, pageLimit) : rows;

return {
batches,
nextCursor: hasMore ? encodeSettlementCursor(batches[batches.length - 1]) : null,
};
}

/**
Expand Down
91 changes: 51 additions & 40 deletions frontend/app/admin/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
SecondaryButton,
inputClass,
} from "@/components/admin/ui";
import { DataTable } from "@/components/admin/data-table";
import { RequireAdmin } from "@/components/admin/require-admin";

const KINDS: LedgerAccountKind[] = [
Expand All @@ -41,9 +42,13 @@

const accounts = useQuery({
queryKey: ["ledger-accounts"],
queryFn: () => listAccounts(),
queryFn: () => listAccounts(undefined, 50),
});

const rows = Array.isArray(accounts.data)
? accounts.data
: accounts.data?.data ?? [];

const freeze = useMutation({
mutationFn: ({ id, frozen }: { id: string; frozen: boolean }) =>
updateAccount(id, { frozen }),
Expand Down Expand Up @@ -76,52 +81,58 @@
)}

<Card>
<CardHeader title={`${accounts.data?.length ?? 0} accounts`} />
{accounts.error && (
<p role="alert" className="px-5 py-6 text-sm text-red-600">
{(accounts.error as Error).message}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase text-gray-500 dark:border-gray-800 dark:text-gray-400">
<tr>
<th className="px-5 py-3">Kind</th>
<th className="px-5 py-3">Owner</th>
<th className="px-5 py-3">Currency</th>
<th className="px-5 py-3 text-right">Balance</th>
<th className="px-5 py-3 text-right">Overdraft</th>
<th className="px-5 py-3">Payout address</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{accounts.data?.map((account) => (
<AccountRow
key={account.id}
account={account}
onToggleFreeze={(frozen) =>
freeze.mutate({ id: account.id, frozen })
}
/>
))}
{!accounts.isLoading && !accounts.error && (accounts.data?.length ?? 0) === 0 && (
<tr>
<td colSpan={8} className="px-5 py-8 text-center text-gray-400">
No accounts found.
</td>
</tr>
<CardHeader title={`${rows.length} accounts`} />
<DataTable
columns={[
{ key: "kind", header: "Kind", render: (account) => (
<Badge className="bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
{(account as LedgerAccount).kind}
</Badge>
) },
{ key: "ownerId", header: "Owner", render: (account) => (
((account as LedgerAccount).ownerId ?? (
<span className="text-gray-400 dark:text-gray-500">—</span>
))
) },
{ key: "currency", header: "Currency", render: (account) => (account as LedgerAccount).currency },
{ key: "balance", header: "Balance", className: "text-right tabular-nums text-gray-900 dark:text-gray-50", render: (account) => (account as LedgerAccount).balance.toLocaleString() },
{ key: "overdraftLimit", header: "Overdraft", className: "text-right tabular-nums text-gray-700 dark:text-gray-300", render: (account) => (account as LedgerAccount).overdraftLimit.toLocaleString() },
{ key: "externalPayoutAddress", header: "Payout address", render: (account) => (
((account as LedgerAccount).externalPayoutAddress ?? (
<span className="text-gray-400 dark:text-gray-500">—</span>
))
) },
{ key: "frozen", header: "Status", render: (account) => <StatusBadge status={(account as LedgerAccount).frozen ? "ABANDONED" : "ACTIVE"} /> },
]}
rows={rows}
rowKey={(account) => (account as LedgerAccount).id}
loading={accounts.isLoading}
error={accounts.error ? <p role="alert" className="text-sm text-red-600">{(accounts.error as Error).message}</p> : undefined}
empty={<span>No accounts found.</span>}
rowActions={(account) => (
<SecondaryButton
type="button"
onClick={() => freeze.mutate({ id: (account as LedgerAccount).id, frozen: !(account as LedgerAccount).frozen })}
className="px-2.5 py-1 text-xs"
>
{(account as LedgerAccount).frozen ? (
<>
<Unlock className="h-3.5 w-3.5" /> Unfreeze
</>
) : (
<>
<Snowflake className="h-3.5 w-3.5" /> Freeze
</>
)}
</tbody>
</table>
</div>
</SecondaryButton>
)}
/>
</Card>
</div>
);
}

function AccountRow({

Check warning on line 135 in frontend/app/admin/accounts/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

'AccountRow' is defined but never used
account,
onToggleFreeze,
}: {
Expand Down
142 changes: 41 additions & 101 deletions frontend/app/admin/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Badge,
StatusBadge,
} from "@/components/admin/ui";
import { DataTable } from "@/components/admin/data-table";
import { RequireAdmin } from "@/components/admin/require-admin";

// Payment amounts arrive as integer minor units.
Expand Down Expand Up @@ -40,8 +41,6 @@ function ManualReviewInner() {

const payments = data?.items ?? [];
const total = data?.total ?? 0;
const hasPrevious = offset > 0;
const hasNext = offset + PAGE_SIZE < total;

return (
<div className="space-y-6">
Expand All @@ -64,105 +63,46 @@ function ManualReviewInner() {
description="Auto-refreshes every 30 seconds."
/>

{error ? (
<p className="px-5 py-6 text-sm text-red-600 dark:text-red-400">
{(error as Error).message}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-gray-200 text-xs uppercase text-gray-500 dark:border-gray-800 dark:text-gray-400">
<tr>
<th className="px-5 py-3">Payment ID</th>
<th className="px-5 py-3">Booking</th>
<th className="px-5 py-3">Rail</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3 text-right">Amount</th>
<th className="px-5 py-3">Review reason</th>
<th className="px-5 py-3">Created</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{payments.map((p: Payment) => (
<tr
key={p.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td className="px-5 py-3 font-mono text-xs text-gray-700 dark:text-gray-300">
{p.id.slice(0, 8)}…
</td>
<td className="px-5 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">
{p.bookingId.slice(0, 8)}…
</td>
<td className="px-5 py-3">
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400">
{p.rail}
</Badge>
</td>
<td className="px-5 py-3">
<StatusBadge status={p.status} />
</td>
<td className="px-5 py-3 text-right tabular-nums">
{formatAmount(p.amount, p.currency)}
</td>
<td className="px-5 py-3 max-w-xs truncate text-gray-500 dark:text-gray-400">
{p.manualReviewReason ?? "—"}
</td>
<td className="px-5 py-3 text-gray-500 dark:text-gray-400">
{new Date(p.createdAt).toLocaleDateString()}
</td>
<td className="px-5 py-3">
<Link
href={`/admin/payments/${p.id}`}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Review <ExternalLink className="h-3 w-3" />
</Link>
</td>
</tr>
))}
{!isLoading && payments.length === 0 && (
<tr>
<td
colSpan={8}
className="px-5 py-10 text-center text-gray-400"
>
No payments in the manual-review queue.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}

{total > 0 && (
<div className="flex items-center justify-between border-t border-gray-200 px-5 py-3 text-sm dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">
Showing {total === 0 ? 0 : offset + 1}–
{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => setOffset((current) => Math.max(0, current - PAGE_SIZE))}
disabled={!hasPrevious}
className="rounded-md border border-gray-200 px-3 py-1 font-medium text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-700 dark:text-gray-300"
>
Previous
</button>
<button
type="button"
onClick={() => setOffset((current) => current + PAGE_SIZE)}
disabled={!hasNext}
className="rounded-md border border-gray-200 px-3 py-1 font-medium text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-700 dark:text-gray-300"
>
Next
</button>
</div>
</div>
)}
<DataTable
columns={[
{ key: "id", header: "Payment ID", render: (payment) => (
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">{(payment as Payment).id.slice(0, 8)}…</span>
) },
{ key: "bookingId", header: "Booking", render: (payment) => (
<span className="font-mono text-xs text-gray-500 dark:text-gray-400">{(payment as Payment).bookingId.slice(0, 8)}…</span>
) },
{ key: "rail", header: "Rail", render: (payment) => (
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-400">
{(payment as Payment).rail}
</Badge>
) },
{ key: "status", header: "Status", render: (payment) => <StatusBadge status={(payment as Payment).status} /> },
{ key: "amount", header: "Amount", className: "text-right tabular-nums", render: (payment) => formatAmount((payment as Payment).amount, (payment as Payment).currency) },
{ key: "manualReviewReason", header: "Review reason", render: (payment) => (
<span className="max-w-xs truncate text-gray-500 dark:text-gray-400">{(payment as Payment).manualReviewReason ?? "—"}</span>
) },
{ key: "createdAt", header: "Created", render: (payment) => (
<span className="text-gray-500 dark:text-gray-400">{new Date((payment as Payment).createdAt).toLocaleDateString()}</span>
) },
]}
rows={payments}
rowKey={(payment) => (payment as Payment).id}
loading={isLoading}
error={error ? <p className="text-sm text-red-600 dark:text-red-400">{(error as Error).message}</p> : undefined}
empty={<span>No payments in the manual-review queue.</span>}
page={Math.floor(offset / PAGE_SIZE) + 1}
pageSize={PAGE_SIZE}
totalRows={total}
onPageChange={(nextPage) => setOffset((nextPage - 1) * PAGE_SIZE)}
rowActions={(payment) => (
<Link
href={`/admin/payments/${(payment as Payment).id}`}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Review <ExternalLink className="h-3 w-3" />
</Link>
)}
/>
</Card>
</div>
);
Expand Down
Loading
Loading