diff --git a/app/(app)/me/me-portal-client.tsx b/app/(app)/me/me-portal-client.tsx index ae9bff4..d113b1e 100644 --- a/app/(app)/me/me-portal-client.tsx +++ b/app/(app)/me/me-portal-client.tsx @@ -52,6 +52,7 @@ export type Reservation = { export type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } export type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null } export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null } +export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string } export type HeldPermission = { code: string; label: string } export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null @@ -60,6 +61,7 @@ type Props = { grants: Grant[] held: HeldPermission[] billing: Billing + payments: MyPayment[] notifs: MyNotif[] classSignups: ClassSignup[] reservations: Reservation[] @@ -74,6 +76,7 @@ export function MePortalClient({ grants, held, billing, + payments, notifs, classSignups, reservations, @@ -135,6 +138,32 @@ export function MePortalClient({ + + My payments + {payments.length === 0 ? ( + + No payments recorded for you yet. + + ) : ( + + {payments.map(p => ( + + + + {p.currency} {p.amount.toFixed(2)} + + + {new Date(p.date).toLocaleDateString()} · {p.platform} + {p.description ? ` · ${p.description}` : ''} + + + {p.status} + + ))} + + )} + + Certifications {grants.length === 0 ? ( diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index 30fde95..18208d0 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { PageHeader, PageTitle } from '@/components/ui/page-title' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' -import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications } from '@/lib/actions' +import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications, getMyPayments } from '@/lib/actions' import { MePortalClient, type Grant, @@ -11,6 +11,7 @@ import { type MyCard, type MyVisit, type MyNotif, + type MyPayment, } from './me-portal-client' export const dynamic = 'force-dynamic' @@ -97,6 +98,9 @@ export default async function MePage() { const notifsRes = await getMyNotifications() const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : [] + const paymentsRes = await getMyPayments() + const payments: MyPayment[] = 'data' in paymentsRes ? (paymentsRes.data as MyPayment[]) : [] + return ( <> @@ -108,6 +112,7 @@ export default async function MePage() { grants={grants} held={held} billing={billing} + payments={payments} notifs={notifs} classSignups={classSignups} reservations={reservations} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a776104..77ca09f 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -967,3 +967,4 @@ Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `li - The Stripe webhook enqueues into `notifications` (`ignoreDuplicates` on the `(space_id, dedupe_key)` unique index, so an event replay is a no-op): `invoice.paid` → `dues_renewed`, `invoice.payment_failed` → `dues_payment_failed`, transition to `late` (in `applySubscription`) → `dues_lapsed`. Recipient + space name resolved server-side; never sends inline. - Dispatcher `POST /api/cron/notifications` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer; 503 if unset, 401 on mismatch; `proxy.ts` whitelists `/api/cron`). Drains ≤20 oldest `pending` email rows with `attempts < MAX`, sends with the row id as Resend `Idempotency-Key` (overlap-safe), ~4.5/s spacing. Success → `sent`+`sent_at`; failure → `attempts++`, `pending` if retryable and budget remains else `failed`. Returns `{ scanned, sent, failed, retried }`. Hit once a minute by the droplet crontab. - `getMyNotifications()` — the caller's own last 15 notifications (service client; `notifications` is admin-RLS, mirrors `getMyBilling`). Surfaced read-only on `/me`. +- `getMyPayments()` — the caller's own last 50 payments (service client; `payments` SELECT is treasurer-scoped, so the self-view is strictly scoped to the caller's `space_id` + `member_id`, same convention as `getMyBilling`). Surfaced read-only in the `/me` Membership tab. diff --git a/lib/actions/payments.ts b/lib/actions/payments.ts index 660c88c..b11b70c 100644 --- a/lib/actions/payments.ts +++ b/lib/actions/payments.ts @@ -2,7 +2,9 @@ import { revalidatePath } from 'next/cache' import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' import { + requireMember, requireMemberWithRole, logActivity, parseInput, @@ -156,3 +158,37 @@ export async function importPaymentsCsv(rows: unknown) { revalidatePath('/dashboard') return { data, count: data.length, skipped } } + +// The caller's own payment history. `payments` SELECT is treasurer-scoped +// (RLS), so the member self-view goes through the validated service client, +// strictly scoped to their own space_id + member_id (same convention as +// getMyBilling / getMyNotifications). +export async function getMyPayments() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data } = await createAdminClient() + .from('payments') + .select('id, amount, currency, platform, description, status, transaction_date, payment_date, created_at') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .order('transaction_date', { ascending: false, nullsFirst: false }) + .limit(50) + + return { + data: (data ?? []).map(p => ({ + id: p.id as string, + amount: p.amount as number, + currency: (p.currency as string | null) ?? 'USD', + platform: p.platform as string, + description: (p.description as string | null) ?? null, + status: p.status as string, + date: + (p.transaction_date as string | null) ?? + (p.payment_date as string | null) ?? + (p.created_at as string), + })), + } +}
+ No payments recorded for you yet. +
+ {new Date(p.date).toLocaleDateString()} · {p.platform} + {p.description ? ` · ${p.description}` : ''} +