Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client';

import { useEffect, useState } from 'react';

// ─── Types ────────────────────────────────────────────────────────────────────

export type RequestStatus = 'pending' | 'paid' | 'expired' | 'cancelled';

interface PaymentRequestExpirationBadgeProps {
status: RequestStatus;
expiresAt: string | Date; // ISO string or Date
expiredAt?: string | Date | null;
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

function msRemaining(expiresAt: Date): number {
return expiresAt.getTime() - Date.now();
}

function formatCountdown(ms: number): string {
if (ms <= 0) return 'Expired';
const totalSeconds = Math.floor(ms / 1000);
const d = Math.floor(totalSeconds / 86400);
const h = Math.floor((totalSeconds % 86400) / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
if (d > 0) return `${d}d ${h}h remaining`;
if (h > 0) return `${h}h ${m}m remaining`;
if (m > 0) return `${m}m ${s}s remaining`;
return `${s}s remaining`;
}

// ─── Component ────────────────────────────────────────────────────────────────

/**
* Displays the expiration state of a payment request.
*
* - pending → live countdown ticker (turns red when < 5 min)
* - expired → "Expired" badge with timestamp
* - paid → "Paid" badge
* - cancelled→ "Cancelled" badge
*/
export function PaymentRequestExpirationBadge({
status,
expiresAt,
expiredAt,
}: PaymentRequestExpirationBadgeProps) {
const deadline = new Date(expiresAt);
const [remaining, setRemaining] = useState<number>(msRemaining(deadline));

// Live countdown — only active for pending requests.
useEffect(() => {
if (status !== 'pending') return;
const interval = setInterval(() => {
setRemaining(msRemaining(deadline));
}, 1000);
return () => clearInterval(interval);
}, [status, deadline]);

if (status === 'paid') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
✓ Paid
</span>
);
}

if (status === 'cancelled') {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
Cancelled
</span>
);
}

if (status === 'expired' || remaining <= 0) {
const expiredDisplay = expiredAt
? new Date(expiredAt).toLocaleString()
: deadline.toLocaleString();
return (
<span
className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
title={`Expired at ${expiredDisplay}`}
>
⏰ Expired
</span>
);
}

// Pending — show countdown, highlight urgency.
const critical = remaining < 5 * 60 * 1000; // < 5 min
const warning = remaining < 30 * 60 * 1000; // < 30 min

const colorClass = critical
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: warning
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';

return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}`}
title={`Expires: ${deadline.toLocaleString()}`}
>
{critical ? '⚠️' : '⏳'} {formatCountdown(remaining)}
</span>
);
}
182 changes: 182 additions & 0 deletions backend/frontend/components/payment-requests/PaymentRequestList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use client';

import { useState, useCallback } from 'react';
import { PaymentRequestExpirationBadge, type RequestStatus } from './PaymentRequestExpirationBadge';

// ─── Types ────────────────────────────────────────────────────────────────────

export interface PaymentRequestItem {
id: string;
amount: string;
currency: string;
status: RequestStatus;
expiresAt: string;
expiredAt?: string | null;
paidAt?: string | null;
requesterAddress: string;
payerAddress?: string | null;
memo?: string | null;
createdAt: string;
}

type FilterStatus = 'all' | RequestStatus;

interface PaymentRequestListProps {
requests: PaymentRequestItem[];
onRenew?: (id: string) => void;
onCancel?: (id: string) => void;
isLoading?: boolean;
}

// ─── Component ────────────────────────────────────────────────────────────────

/**
* Dashboard table for payment requests.
*
* Features:
* - Filter by status (all / pending / paid / expired / cancelled)
* - Live expiration countdown per row via PaymentRequestExpirationBadge
* - Renew action for expired/cancelled requests
* - Cancel action for pending requests
*/
export function PaymentRequestList({
requests,
onRenew,
onCancel,
isLoading = false,
}: PaymentRequestListProps) {
const [filter, setFilter] = useState<FilterStatus>('all');

const filtered = useCallback(
() =>
filter === 'all'
? requests
: requests.filter((r) => r.status === filter),
[requests, filter],
)();

const filterButtons: { label: string; value: FilterStatus }[] = [
{ label: 'All', value: 'all' },
{ label: 'Pending', value: 'pending' },
{ label: 'Paid', value: 'paid' },
{ label: 'Expired', value: 'expired' },
{ label: 'Cancelled', value: 'cancelled' },
];

return (
<div className="w-full space-y-4">
{/* ── Filter bar ── */}
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter payment requests by status">
{filterButtons.map(({ label, value }) => (
<button
key={value}
onClick={() => setFilter(value)}
aria-pressed={filter === value}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
filter === value
? 'bg-indigo-600 text-white focus-visible:ring-indigo-500'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 focus-visible:ring-gray-400'
}`}
>
{label}
{value !== 'all' && (
<span className="ml-1.5 text-xs opacity-70">
({requests.filter((r) => r.status === value).length})
</span>
)}
</button>
))}
</div>

{/* ── Table ── */}
{isLoading ? (
<p className="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400">No payment requests found.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{['ID', 'Amount', 'Status / Expiry', 'From / To', 'Created', 'Actions'].map(
(h) => (
<th
key={h}
scope="col"
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white dark:divide-gray-800 dark:bg-gray-900">
{filtered.map((req) => (
<tr key={req.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
{/* ID */}
<td className="px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400">
{req.id.slice(0, 8)}…
</td>

{/* Amount */}
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">
{req.amount} {req.currency}
</td>

{/* Status / Expiry */}
<td className="px-4 py-3">
<PaymentRequestExpirationBadge
status={req.status}
expiresAt={req.expiresAt}
expiredAt={req.expiredAt}
/>
</td>

{/* Addresses */}
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
<div title={req.requesterAddress}>
From: {req.requesterAddress.slice(0, 8)}…
</div>
{req.payerAddress && (
<div title={req.payerAddress}>
To: {req.payerAddress.slice(0, 8)}…
</div>
)}
</td>

{/* Created */}
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{new Date(req.createdAt).toLocaleDateString()}
</td>

{/* Actions */}
<td className="px-4 py-3">
<div className="flex gap-2">
{(req.status === 'expired' || req.status === 'cancelled') && onRenew && (
<button
onClick={() => onRenew(req.id)}
className="rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
Renew
</button>
)}
{req.status === 'pending' && onCancel && (
<button
onClick={() => onCancel(req.id)}
className="rounded bg-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
>
Cancel
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Migration: Add PaymentRequest model with expiration fields
-- Issue #460 — Payment Request Expiration with Smart Contract Enforcement

CREATE TYPE "PaymentRequestStatus" AS ENUM ('pending', 'paid', 'expired', 'cancelled');

CREATE TABLE "payment_requests" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
"tenant_id" TEXT NOT NULL,
"requester_id" TEXT NOT NULL,
"payer_address" TEXT,
"requester_address" TEXT NOT NULL,
"amount" DECIMAL(20,8) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'XLM',
"network" TEXT NOT NULL DEFAULT 'stellar',
"token_address" TEXT,
"status" "PaymentRequestStatus" NOT NULL DEFAULT 'pending',
"expires_at" TIMESTAMP(3) NOT NULL,
"expired_at" TIMESTAMP(3),
"paid_at" TIMESTAMP(3),
"contract_request_id" TEXT,
"memo" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMP(3),

CONSTRAINT "payment_requests_pkey" PRIMARY KEY ("id")
);

-- Indexes for dashboard filtering and sweep cron
CREATE INDEX "payment_requests_tenant_status_idx" ON "payment_requests"("tenant_id", "status");
CREATE INDEX "payment_requests_expires_at_idx" ON "payment_requests"("expires_at");
CREATE INDEX "payment_requests_status_expires_idx" ON "payment_requests"("status", "expires_at");
CREATE INDEX "payment_requests_requester_id_idx" ON "payment_requests"("requester_id");
CREATE INDEX "payment_requests_payer_address_idx" ON "payment_requests"("payer_address");
CREATE INDEX "payment_requests_created_at_idx" ON "payment_requests"("created_at");
Loading
Loading