feat(me): my-payments self-view in the Membership tab (P3d)
New getMyPayments: payments SELECT is treasurer-scoped (RLS), so the self-view uses the validated service client strictly scoped to the caller's space_id + member_id (same convention as getMyBilling / getMyNotifications). Read-only list (amount, date, platform, status) in the Membership tab. Docs updated. 521 tests; build clean.
This commit is contained in:
@@ -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 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 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 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 HeldPermission = { code: string; label: string }
|
||||||
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null
|
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ type Props = {
|
|||||||
grants: Grant[]
|
grants: Grant[]
|
||||||
held: HeldPermission[]
|
held: HeldPermission[]
|
||||||
billing: Billing
|
billing: Billing
|
||||||
|
payments: MyPayment[]
|
||||||
notifs: MyNotif[]
|
notifs: MyNotif[]
|
||||||
classSignups: ClassSignup[]
|
classSignups: ClassSignup[]
|
||||||
reservations: Reservation[]
|
reservations: Reservation[]
|
||||||
@@ -74,6 +76,7 @@ export function MePortalClient({
|
|||||||
grants,
|
grants,
|
||||||
held,
|
held,
|
||||||
billing,
|
billing,
|
||||||
|
payments,
|
||||||
notifs,
|
notifs,
|
||||||
classSignups,
|
classSignups,
|
||||||
reservations,
|
reservations,
|
||||||
@@ -135,6 +138,32 @@ export function MePortalClient({
|
|||||||
<DuesCard billing={billing} />
|
<DuesCard billing={billing} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className={sectionH}>My payments</h2>
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<p className="font-sans text-sm text-muted-foreground">
|
||||||
|
No payments recorded for you yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-lg border border-border">
|
||||||
|
{payments.map(p => (
|
||||||
|
<li key={p.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-sans text-sm font-semibold text-foreground">
|
||||||
|
{p.currency} {p.amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||||
|
{new Date(p.date).toLocaleDateString()} · {p.platform}
|
||||||
|
{p.description ? ` · ${p.description}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={p.status === 'linked' ? 'default' : 'outline'}>{p.status}</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className={sectionH}>Certifications</h2>
|
<h2 className={sectionH}>Certifications</h2>
|
||||||
{grants.length === 0 ? (
|
{grants.length === 0 ? (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
import { PageHeader, PageTitle } from '@/components/ui/page-title'
|
||||||
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
|
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 {
|
import {
|
||||||
MePortalClient,
|
MePortalClient,
|
||||||
type Grant,
|
type Grant,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type MyCard,
|
type MyCard,
|
||||||
type MyVisit,
|
type MyVisit,
|
||||||
type MyNotif,
|
type MyNotif,
|
||||||
|
type MyPayment,
|
||||||
} from './me-portal-client'
|
} from './me-portal-client'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -97,6 +98,9 @@ export default async function MePage() {
|
|||||||
const notifsRes = await getMyNotifications()
|
const notifsRes = await getMyNotifications()
|
||||||
const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : []
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
@@ -108,6 +112,7 @@ export default async function MePage() {
|
|||||||
grants={grants}
|
grants={grants}
|
||||||
held={held}
|
held={held}
|
||||||
billing={billing}
|
billing={billing}
|
||||||
|
payments={payments}
|
||||||
notifs={notifs}
|
notifs={notifs}
|
||||||
classSignups={classSignups}
|
classSignups={classSignups}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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`.
|
- `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.
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import {
|
import {
|
||||||
|
requireMember,
|
||||||
requireMemberWithRole,
|
requireMemberWithRole,
|
||||||
logActivity,
|
logActivity,
|
||||||
parseInput,
|
parseInput,
|
||||||
@@ -156,3 +158,37 @@ export async function importPaymentsCsv(rows: unknown) {
|
|||||||
revalidatePath('/dashboard')
|
revalidatePath('/dashboard')
|
||||||
return { data, count: data.length, skipped }
|
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),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user