Skip to content

Commit 75404d0

Browse files
feat(payments): binance_personal provider — Binance Pay on a personal account via read-only Pay-history polling (#482)
New poll-confirmed provider mirroring the Solana one-time model, for schools without a KYB'd merchant account: - Provider: binance_personal in PaymentProvider union + PROVIDER_CAPABILITIES (selfManagedPeriod only), BinancePersonalProvider with signed (HMAC-SHA256) read-only GET /sapi/v1/pay/transactions client; new CheckoutSession kind 'instructions' carrying payId/amount/code - Checkout: unified route resolves the tenant Pay ID into destinationAccount; checkout-form renders a transfer-instructions panel (Pay ID QR, exact USDT amount, copyable note code) and polls the verify endpoint every 10s - Confirmation: shared reconcile core (note-code match, exact-amount single-pending match, ambiguous -> never guess) consumes the orderId via the provider_charge_id partial unique index; verify endpoint + CRON_SECRET- guarded reconciler cron batching one Pay-history fetch per tenant (24h TTL) - Credentials: per-tenant Pay ID + API key/secret stored in tenant_payment_wallets with AES-256-GCM app-level encryption under new PAYMENT_CREDENTIALS_ENCRYPTION_KEY (fails closed; secrets never returned) - Admin: settings toggle + credentials form (read-only-key warning), pending binance_personal payments section on payment-requests with dialog-guarded manual confirm/cancel actions; product wizard + product/plan forms gated by getEnabledPaymentProviders (flag AND configured wallet) - Fixes: getAllSettingsByCategory now categorizes binance_enabled / binance_personal_enabled under payment (both toggles previously never hydrated) - Migration: append binance_personal to products/plans payment_provider CHECK - Tests: 27 new unit tests (signing, history normalization, matching rules, idempotency, credentials round-trip, capabilities sync); en/es i18n Closes #482 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pv8LXZRG4UdCdu9sd39Kct
1 parent 6126c1d commit 75404d0

28 files changed

Lines changed: 2361 additions & 61 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ PAYMENT_PROVIDER=stripe # Has-Default — stripe | ma
6565
BINANCE_PAY_API_KEY= # Optional — Binance Pay merchant API key (certificate SN)
6666
BINANCE_PAY_API_SECRET= # Optional — Binance Pay merchant API secret
6767

68+
# ─── PAYMENTS — CREDENTIALS AT REST ───────────────────────────────────────────
69+
PAYMENT_CREDENTIALS_ENCRYPTION_KEY= # Optional — master key (AES-256-GCM) for per-tenant payment API secrets (binance_personal); REQUIRED to enable binance_personal
70+
6871
# ─── PAYMENTS — LEMON SQUEEZY (Merchant of Record) ────────────────────────────
6972
LEMONSQUEEZY_API_KEY= # Optional — LS API key (test or live, mode-specific)
7073
LEMONSQUEEZY_STORE_ID= # Optional — numeric LS store id

app/[locale]/dashboard/admin/payment-requests/page.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { getTranslations } from 'next-intl/server'
44
import { getUserRole, isSuperAdmin } from '@/lib/supabase/get-user-role'
55
import {getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
66
import { PaymentRequestsTable } from '@/components/admin/payment-requests-table'
7-
import { Card, CardContent } from '@/components/ui/card'
7+
import { BinancePersonalPendingTable } from '@/components/admin/binance-personal-pending-table'
8+
import { listPendingBinancePersonalTransactions } from '@/app/actions/admin/binance-personal'
9+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
810
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
911
import { Button } from '@/components/ui/button'
1012
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
@@ -62,6 +64,9 @@ export default async function PaymentRequestsPage({
6264

6365
const requests = allRequests || []
6466

67+
// Pending binance_personal transactions that need manual admin confirmation (#482)
68+
const pendingBinancePersonal = await listPendingBinancePersonalTransactions()
69+
6570
// Count by status
6671
const pendingCount = requests.filter(r => r.status === 'pending').length
6772
const contactedCount = requests.filter(r => r.status === 'contacted').length
@@ -187,6 +192,19 @@ export default async function PaymentRequestsPage({
187192
<PaymentRequestsTable requests={requests} />
188193
</TabsContent>
189194
</Tabs>
195+
196+
{/* Pending Binance Pay (personal) payments awaiting manual confirmation (#482) */}
197+
{pendingBinancePersonal.length > 0 && (
198+
<Card>
199+
<CardHeader>
200+
<CardTitle>{t('binancePersonal.title')}</CardTitle>
201+
<CardDescription>{t('binancePersonal.description')}</CardDescription>
202+
</CardHeader>
203+
<CardContent>
204+
<BinancePersonalPendingTable transactions={pendingBinancePersonal} />
205+
</CardContent>
206+
</Card>
207+
)}
190208
</main>
191209
</div>
192210
)

app/[locale]/dashboard/admin/products/[productId]/edit/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const supportedPaymentProviders = new Set<ProductCreationPaymentProvider>([
3535
'stripe',
3636
'paypal',
3737
'binance',
38+
'binance_personal',
3839
])
3940

4041
function getSupportedPaymentProvider(value: string | null): ProductCreationPaymentProvider {

app/[locale]/dashboard/admin/settings/page.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getUserRole } from '@/lib/supabase/get-user-role'
22
import { redirect } from 'next/navigation'
33
import { getTranslations } from 'next-intl/server'
44
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
5-
import { getAllSettingsByCategory, getSolanaWallet } from '@/app/actions/admin/settings'
5+
import { getAllSettingsByCategory, getSolanaWallet, getBinancePersonalStatus } from '@/app/actions/admin/settings'
66
import { getOrCreateTenantReferralCode } from '@/app/actions/admin/referrals'
77
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
88
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -14,6 +14,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
1414
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
1515
import { syncConnectAccountStatus } from '@/lib/stripe-connect'
1616
import SolanaWalletForm from '@/components/admin/solana-wallet-form'
17+
import BinancePersonalForm from '@/components/admin/binance-personal-form'
1718
import EnrollmentSettingsForm from '@/components/admin/enrollment-settings-form'
1819
import { ReferralLinkCard } from '@/components/admin/referral-link-card'
1920
import { ToursToggle } from '@/components/shared/tours-toggle'
@@ -57,6 +58,10 @@ export default async function SettingsPage({
5758
const solanaWallet = await getSolanaWallet().catch(() => null)
5859
const solanaWalletAddress = solanaWallet?.data?.wallet_address || ''
5960

61+
// Binance Pay (personal account) status — Pay ID + whether credentials are
62+
// stored. Secrets are never fetched (#482).
63+
const binancePersonal = await getBinancePersonalStatus().catch(() => null)
64+
6065
// Stripe Connect status for the payment tab card (#434)
6166
const tenantId = await getCurrentTenantId()
6267
const { data: tenant } = await createAdminClient()
@@ -186,6 +191,23 @@ export default async function SettingsPage({
186191
<SolanaWalletForm initialAddress={solanaWalletAddress} />
187192
</CardContent>
188193
</Card>
194+
195+
{/* Binance Pay (personal account) — school's own Pay ID + a
196+
read-only API key/secret, encrypted at rest (#482). */}
197+
<Card className="mt-6">
198+
<CardHeader>
199+
<CardTitle>{t('sections.binancePersonal.title')}</CardTitle>
200+
<CardDescription>
201+
{t('sections.binancePersonal.description')}
202+
</CardDescription>
203+
</CardHeader>
204+
<CardContent>
205+
<BinancePersonalForm
206+
initialPayId={binancePersonal?.payId ?? null}
207+
hasCredentials={binancePersonal?.hasCredentials ?? false}
208+
/>
209+
</CardContent>
210+
</Card>
189211
</TabsContent>
190212

191213
{/* Enrollment Settings */}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use server'
2+
3+
import { createAdminClient } from '@/lib/supabase/admin'
4+
import { getUserRole } from '@/lib/supabase/get-user-role'
5+
import { getCurrentTenantId } from '@/lib/supabase/tenant'
6+
import { revalidatePath } from 'next/cache'
7+
8+
interface ActionResponse {
9+
success: boolean
10+
error?: string
11+
}
12+
13+
export interface PendingBinancePersonalTransaction {
14+
transaction_id: number
15+
amount: number | null
16+
currency: string | null
17+
transaction_date: string | null
18+
user_id: string
19+
full_name: string
20+
}
21+
22+
/**
23+
* Admin manually confirms an ambiguous `binance_personal` transaction (issue #482).
24+
*
25+
* Personal Binance Pay has no webhook, and when a buyer omits the note code and
26+
* the amount collides with another transfer, automated reconciliation can't
27+
* safely attribute the payment — it stays `pending`. Once the school admin has
28+
* verified the transfer in their own Binance app, they flip it here.
29+
*
30+
* Uses the service-role client (bypasses RLS), so admin role + tenant scope +
31+
* provider are validated below. The status-guarded update (`.eq('status','pending')`)
32+
* keeps it idempotent; the after_transaction_update trigger creates the
33+
* entitlements on the flip, so we never call enroll RPCs. No provider_charge_id
34+
* is set — a manual confirmation has no Binance orderId to consume.
35+
*/
36+
export async function confirmBinancePersonalTransaction(
37+
transactionId: number
38+
): Promise<ActionResponse> {
39+
try {
40+
const role = await getUserRole()
41+
if (role !== 'admin') {
42+
return { success: false, error: 'Unauthorized' }
43+
}
44+
45+
const tenantId = await getCurrentTenantId()
46+
const supabase = createAdminClient()
47+
48+
const { data: tx, error: loadError } = await supabase
49+
.from('transactions')
50+
.select('transaction_id, tenant_id, payment_provider, status')
51+
.eq('transaction_id', transactionId)
52+
.maybeSingle()
53+
54+
if (loadError) throw loadError
55+
if (!tx || tx.tenant_id !== tenantId || tx.payment_provider !== 'binance_personal') {
56+
return { success: false, error: 'Transaction not found' }
57+
}
58+
59+
const { error: updateError } = await supabase
60+
.from('transactions')
61+
.update({ status: 'successful' })
62+
.eq('transaction_id', transactionId)
63+
.eq('tenant_id', tenantId)
64+
.eq('payment_provider', 'binance_personal')
65+
.eq('status', 'pending')
66+
67+
if (updateError) throw updateError
68+
69+
revalidatePath('/dashboard/admin/payment-requests')
70+
71+
return { success: true }
72+
} catch (error) {
73+
console.error('Error confirming Binance personal transaction:', error)
74+
return { success: false, error: 'Failed to confirm payment' }
75+
}
76+
}
77+
78+
/**
79+
* Admin cancels a pending `binance_personal` transaction (issue #482) — used
80+
* when they've determined no payment ever arrived. Flips pending → canceled
81+
* with the same gating, ownership, and provider checks as the confirm action.
82+
*/
83+
export async function cancelBinancePersonalTransaction(
84+
transactionId: number
85+
): Promise<ActionResponse> {
86+
try {
87+
const role = await getUserRole()
88+
if (role !== 'admin') {
89+
return { success: false, error: 'Unauthorized' }
90+
}
91+
92+
const tenantId = await getCurrentTenantId()
93+
const supabase = createAdminClient()
94+
95+
const { data: tx, error: loadError } = await supabase
96+
.from('transactions')
97+
.select('transaction_id, tenant_id, payment_provider, status')
98+
.eq('transaction_id', transactionId)
99+
.maybeSingle()
100+
101+
if (loadError) throw loadError
102+
if (!tx || tx.tenant_id !== tenantId || tx.payment_provider !== 'binance_personal') {
103+
return { success: false, error: 'Transaction not found' }
104+
}
105+
106+
const { error: updateError } = await supabase
107+
.from('transactions')
108+
.update({ status: 'canceled' })
109+
.eq('transaction_id', transactionId)
110+
.eq('tenant_id', tenantId)
111+
.eq('payment_provider', 'binance_personal')
112+
.eq('status', 'pending')
113+
114+
if (updateError) throw updateError
115+
116+
revalidatePath('/dashboard/admin/payment-requests')
117+
118+
return { success: true }
119+
} catch (error) {
120+
console.error('Error canceling Binance personal transaction:', error)
121+
return { success: false, error: 'Failed to cancel payment' }
122+
}
123+
}
124+
125+
/**
126+
* List this tenant's pending `binance_personal` transactions for the admin
127+
* manual-confirmation queue (issue #482). Oldest first. `profiles` is global
128+
* and has no email column, so the buyer's display name is looked up via
129+
* `full_name`.
130+
*/
131+
export async function listPendingBinancePersonalTransactions(): Promise<
132+
PendingBinancePersonalTransaction[]
133+
> {
134+
try {
135+
const role = await getUserRole()
136+
if (role !== 'admin') {
137+
return []
138+
}
139+
140+
const tenantId = await getCurrentTenantId()
141+
const supabase = createAdminClient()
142+
143+
const { data, error } = await supabase
144+
.from('transactions')
145+
.select('transaction_id, amount, currency, transaction_date, user_id')
146+
.eq('tenant_id', tenantId)
147+
.eq('payment_provider', 'binance_personal')
148+
.eq('status', 'pending')
149+
.order('transaction_date', { ascending: true })
150+
151+
if (error) throw error
152+
153+
const rows = data || []
154+
if (rows.length === 0) return []
155+
156+
const userIds = [...new Set(rows.map((r) => r.user_id).filter(Boolean))]
157+
const { data: profiles } = await supabase
158+
.from('profiles')
159+
.select('id, full_name')
160+
.in('id', userIds)
161+
162+
const nameById = new Map<string, string>(
163+
(profiles || []).map((p: { id: string; full_name: string | null }) => [
164+
p.id,
165+
p.full_name || '',
166+
])
167+
)
168+
169+
return rows.map((r) => ({
170+
transaction_id: r.transaction_id,
171+
amount: r.amount,
172+
currency: r.currency,
173+
transaction_date: r.transaction_date,
174+
user_id: r.user_id,
175+
full_name: nameById.get(r.user_id) || '',
176+
}))
177+
} catch (error) {
178+
console.error('Error listing pending Binance personal transactions:', error)
179+
return []
180+
}
181+
}

0 commit comments

Comments
 (0)