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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ PAYMENT_PROVIDER=stripe # Has-Default — stripe | ma
BINANCE_PAY_API_KEY= # Optional — Binance Pay merchant API key (certificate SN)
BINANCE_PAY_API_SECRET= # Optional — Binance Pay merchant API secret

# ─── PAYMENTS — CREDENTIALS AT REST ───────────────────────────────────────────
PAYMENT_CREDENTIALS_ENCRYPTION_KEY= # Optional — master key (AES-256-GCM) for per-tenant payment API secrets (binance_personal); REQUIRED to enable binance_personal

# ─── PAYMENTS — LEMON SQUEEZY (Merchant of Record) ────────────────────────────
LEMONSQUEEZY_API_KEY= # Optional — LS API key (test or live, mode-specific)
LEMONSQUEEZY_STORE_ID= # Optional — numeric LS store id
Expand Down
20 changes: 19 additions & 1 deletion app/[locale]/dashboard/admin/payment-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { getTranslations } from 'next-intl/server'
import { getUserRole, isSuperAdmin } from '@/lib/supabase/get-user-role'
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
import { PaymentRequestsTable } from '@/components/admin/payment-requests-table'
import { Card, CardContent } from '@/components/ui/card'
import { BinancePersonalPendingTable } from '@/components/admin/binance-personal-pending-table'
import { listPendingBinancePersonalTransactions } from '@/app/actions/admin/binance-personal'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
Expand Down Expand Up @@ -62,6 +64,9 @@ export default async function PaymentRequestsPage({

const requests = allRequests || []

// Pending binance_personal transactions that need manual admin confirmation (#482)
const pendingBinancePersonal = await listPendingBinancePersonalTransactions()

// Count by status
const pendingCount = requests.filter(r => r.status === 'pending').length
const contactedCount = requests.filter(r => r.status === 'contacted').length
Expand Down Expand Up @@ -187,6 +192,19 @@ export default async function PaymentRequestsPage({
<PaymentRequestsTable requests={requests} />
</TabsContent>
</Tabs>

{/* Pending Binance Pay (personal) payments awaiting manual confirmation (#482) */}
{pendingBinancePersonal.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t('binancePersonal.title')}</CardTitle>
<CardDescription>{t('binancePersonal.description')}</CardDescription>
</CardHeader>
<CardContent>
<BinancePersonalPendingTable transactions={pendingBinancePersonal} />
</CardContent>
</Card>
)}
</main>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const supportedPaymentProviders = new Set<ProductCreationPaymentProvider>([
'stripe',
'paypal',
'binance',
'binance_personal',
])

function getSupportedPaymentProvider(value: string | null): ProductCreationPaymentProvider {
Expand Down
24 changes: 23 additions & 1 deletion app/[locale]/dashboard/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getUserRole } from '@/lib/supabase/get-user-role'
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { getAllSettingsByCategory, getSolanaWallet } from '@/app/actions/admin/settings'
import { getAllSettingsByCategory, getSolanaWallet, getBinancePersonalStatus } from '@/app/actions/admin/settings'
import { getOrCreateTenantReferralCode } from '@/app/actions/admin/referrals'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
Expand All @@ -14,6 +14,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
import { syncConnectAccountStatus } from '@/lib/stripe-connect'
import SolanaWalletForm from '@/components/admin/solana-wallet-form'
import BinancePersonalForm from '@/components/admin/binance-personal-form'
import EnrollmentSettingsForm from '@/components/admin/enrollment-settings-form'
import { ReferralLinkCard } from '@/components/admin/referral-link-card'
import { ToursToggle } from '@/components/shared/tours-toggle'
Expand Down Expand Up @@ -57,6 +58,10 @@ export default async function SettingsPage({
const solanaWallet = await getSolanaWallet().catch(() => null)
const solanaWalletAddress = solanaWallet?.data?.wallet_address || ''

// Binance Pay (personal account) status — Pay ID + whether credentials are
// stored. Secrets are never fetched (#482).
const binancePersonal = await getBinancePersonalStatus().catch(() => null)

// Stripe Connect status for the payment tab card (#434)
const tenantId = await getCurrentTenantId()
const { data: tenant } = await createAdminClient()
Expand Down Expand Up @@ -186,6 +191,23 @@ export default async function SettingsPage({
<SolanaWalletForm initialAddress={solanaWalletAddress} />
</CardContent>
</Card>

{/* Binance Pay (personal account) — school's own Pay ID + a
read-only API key/secret, encrypted at rest (#482). */}
<Card className="mt-6">
<CardHeader>
<CardTitle>{t('sections.binancePersonal.title')}</CardTitle>
<CardDescription>
{t('sections.binancePersonal.description')}
</CardDescription>
</CardHeader>
<CardContent>
<BinancePersonalForm
initialPayId={binancePersonal?.payId ?? null}
hasCredentials={binancePersonal?.hasCredentials ?? false}
/>
</CardContent>
</Card>
</TabsContent>

{/* Enrollment Settings */}
Expand Down
181 changes: 181 additions & 0 deletions app/actions/admin/binance-personal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
'use server'

import { createAdminClient } from '@/lib/supabase/admin'
import { getUserRole } from '@/lib/supabase/get-user-role'
import { getCurrentTenantId } from '@/lib/supabase/tenant'
import { revalidatePath } from 'next/cache'

interface ActionResponse {
success: boolean
error?: string
}

export interface PendingBinancePersonalTransaction {
transaction_id: number
amount: number | null
currency: string | null
transaction_date: string | null
user_id: string
full_name: string
}

/**
* Admin manually confirms an ambiguous `binance_personal` transaction (issue #482).
*
* Personal Binance Pay has no webhook, and when a buyer omits the note code and
* the amount collides with another transfer, automated reconciliation can't
* safely attribute the payment — it stays `pending`. Once the school admin has
* verified the transfer in their own Binance app, they flip it here.
*
* Uses the service-role client (bypasses RLS), so admin role + tenant scope +
* provider are validated below. The status-guarded update (`.eq('status','pending')`)
* keeps it idempotent; the after_transaction_update trigger creates the
* entitlements on the flip, so we never call enroll RPCs. No provider_charge_id
* is set — a manual confirmation has no Binance orderId to consume.
*/
export async function confirmBinancePersonalTransaction(
transactionId: number
): Promise<ActionResponse> {
try {
const role = await getUserRole()
if (role !== 'admin') {
return { success: false, error: 'Unauthorized' }
}

const tenantId = await getCurrentTenantId()
const supabase = createAdminClient()

const { data: tx, error: loadError } = await supabase
.from('transactions')
.select('transaction_id, tenant_id, payment_provider, status')
.eq('transaction_id', transactionId)
.maybeSingle()

if (loadError) throw loadError
if (!tx || tx.tenant_id !== tenantId || tx.payment_provider !== 'binance_personal') {
return { success: false, error: 'Transaction not found' }
}

const { error: updateError } = await supabase
.from('transactions')
.update({ status: 'successful' })
.eq('transaction_id', transactionId)
.eq('tenant_id', tenantId)
.eq('payment_provider', 'binance_personal')
.eq('status', 'pending')

if (updateError) throw updateError

revalidatePath('/dashboard/admin/payment-requests')

return { success: true }
} catch (error) {
console.error('Error confirming Binance personal transaction:', error)
return { success: false, error: 'Failed to confirm payment' }
}
}

/**
* Admin cancels a pending `binance_personal` transaction (issue #482) — used
* when they've determined no payment ever arrived. Flips pending → canceled
* with the same gating, ownership, and provider checks as the confirm action.
*/
export async function cancelBinancePersonalTransaction(
transactionId: number
): Promise<ActionResponse> {
try {
const role = await getUserRole()
if (role !== 'admin') {
return { success: false, error: 'Unauthorized' }
}

const tenantId = await getCurrentTenantId()
const supabase = createAdminClient()

const { data: tx, error: loadError } = await supabase
.from('transactions')
.select('transaction_id, tenant_id, payment_provider, status')
.eq('transaction_id', transactionId)
.maybeSingle()

if (loadError) throw loadError
if (!tx || tx.tenant_id !== tenantId || tx.payment_provider !== 'binance_personal') {
return { success: false, error: 'Transaction not found' }
}

const { error: updateError } = await supabase
.from('transactions')
.update({ status: 'canceled' })
.eq('transaction_id', transactionId)
.eq('tenant_id', tenantId)
.eq('payment_provider', 'binance_personal')
.eq('status', 'pending')

if (updateError) throw updateError

revalidatePath('/dashboard/admin/payment-requests')

return { success: true }
} catch (error) {
console.error('Error canceling Binance personal transaction:', error)
return { success: false, error: 'Failed to cancel payment' }
}
}

/**
* List this tenant's pending `binance_personal` transactions for the admin
* manual-confirmation queue (issue #482). Oldest first. `profiles` is global
* and has no email column, so the buyer's display name is looked up via
* `full_name`.
*/
export async function listPendingBinancePersonalTransactions(): Promise<
PendingBinancePersonalTransaction[]
> {
try {
const role = await getUserRole()
if (role !== 'admin') {
return []
}

const tenantId = await getCurrentTenantId()
const supabase = createAdminClient()

const { data, error } = await supabase
.from('transactions')
.select('transaction_id, amount, currency, transaction_date, user_id')
.eq('tenant_id', tenantId)
.eq('payment_provider', 'binance_personal')
.eq('status', 'pending')
.order('transaction_date', { ascending: true })

if (error) throw error

const rows = data || []
if (rows.length === 0) return []

const userIds = [...new Set(rows.map((r) => r.user_id).filter(Boolean))]
const { data: profiles } = await supabase
.from('profiles')
.select('id, full_name')
.in('id', userIds)

const nameById = new Map<string, string>(
(profiles || []).map((p: { id: string; full_name: string | null }) => [
p.id,
p.full_name || '',
])
)

return rows.map((r) => ({
transaction_id: r.transaction_id,
amount: r.amount,
currency: r.currency,
transaction_date: r.transaction_date,
user_id: r.user_id,
full_name: nameById.get(r.user_id) || '',
}))
} catch (error) {
console.error('Error listing pending Binance personal transactions:', error)
return []
}
}
Loading
Loading