Add crypto billing options and trial subscriptions (#680)
This commit is contained in:
parent
a1afa0d829
commit
dda0dbbcd4
@ -281,12 +281,14 @@ type passwordResetConfirmRequest struct {
|
||||
}
|
||||
|
||||
type subscriptionUpsertRequest struct {
|
||||
ExternalID string `json:"externalId"`
|
||||
Provider string `json:"provider"`
|
||||
Kind string `json:"kind"`
|
||||
PlanID string `json:"planId"`
|
||||
Status string `json:"status"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
ExternalID string `json:"externalId"`
|
||||
Provider string `json:"provider"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
PaymentQRCode string `json:"paymentQr"`
|
||||
Kind string `json:"kind"`
|
||||
PlanID string `json:"planId"`
|
||||
Status string `json:"status"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
}
|
||||
|
||||
type subscriptionCancelRequest struct {
|
||||
@ -408,6 +410,26 @@ func (h *handler) register(c *gin.Context) {
|
||||
h.removeRegistrationVerification(email)
|
||||
}
|
||||
|
||||
trialExpiresAt := time.Now().UTC().Add(7 * 24 * time.Hour)
|
||||
trial := &store.Subscription{
|
||||
UserID: user.ID,
|
||||
Provider: "trial",
|
||||
PaymentMethod: "trial",
|
||||
Kind: "trial",
|
||||
PlanID: "TRIAL-7D",
|
||||
ExternalID: fmt.Sprintf("trial-%s", user.ID),
|
||||
Status: "active",
|
||||
Meta: map[string]any{
|
||||
"startsAt": time.Now().UTC(),
|
||||
"expiresAt": trialExpiresAt,
|
||||
"note": "new user full-access trial",
|
||||
},
|
||||
}
|
||||
|
||||
if err := h.store.UpsertSubscription(c.Request.Context(), trial); err != nil {
|
||||
slog.Warn("failed to provision onboarding trial", "err", err, "userID", user.ID)
|
||||
}
|
||||
|
||||
message := "registration successful"
|
||||
|
||||
response := gin.H{
|
||||
@ -1997,6 +2019,11 @@ func (h *handler) upsertSubscription(c *gin.Context) {
|
||||
if provider == "" {
|
||||
provider = "paypal"
|
||||
}
|
||||
paymentMethod := strings.TrimSpace(req.PaymentMethod)
|
||||
if paymentMethod == "" {
|
||||
paymentMethod = provider
|
||||
}
|
||||
paymentQRCode := strings.TrimSpace(req.PaymentQRCode)
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
if kind == "" {
|
||||
kind = "subscription"
|
||||
@ -2007,13 +2034,15 @@ func (h *handler) upsertSubscription(c *gin.Context) {
|
||||
}
|
||||
|
||||
sub := &store.Subscription{
|
||||
UserID: user.ID,
|
||||
Provider: provider,
|
||||
Kind: kind,
|
||||
PlanID: strings.TrimSpace(req.PlanID),
|
||||
ExternalID: externalID,
|
||||
Status: status,
|
||||
Meta: req.Meta,
|
||||
UserID: user.ID,
|
||||
Provider: provider,
|
||||
PaymentMethod: paymentMethod,
|
||||
PaymentQRCode: paymentQRCode,
|
||||
Kind: kind,
|
||||
PlanID: strings.TrimSpace(req.PlanID),
|
||||
ExternalID: externalID,
|
||||
Status: status,
|
||||
Meta: req.Meta,
|
||||
}
|
||||
|
||||
if err := h.store.UpsertSubscription(c.Request.Context(), sub); err != nil {
|
||||
@ -2099,16 +2128,18 @@ func sanitizeSubscription(sub *store.Subscription) gin.H {
|
||||
}
|
||||
|
||||
payload := gin.H{
|
||||
"id": sub.ID,
|
||||
"userId": sub.UserID,
|
||||
"provider": sub.Provider,
|
||||
"kind": sub.Kind,
|
||||
"planId": sub.PlanID,
|
||||
"externalId": sub.ExternalID,
|
||||
"status": sub.Status,
|
||||
"meta": meta,
|
||||
"createdAt": sub.CreatedAt.UTC(),
|
||||
"updatedAt": sub.UpdatedAt.UTC(),
|
||||
"id": sub.ID,
|
||||
"userId": sub.UserID,
|
||||
"provider": sub.Provider,
|
||||
"paymentMethod": sub.PaymentMethod,
|
||||
"paymentQr": strings.TrimSpace(sub.PaymentQRCode),
|
||||
"kind": sub.Kind,
|
||||
"planId": sub.PlanID,
|
||||
"externalId": sub.ExternalID,
|
||||
"status": sub.Status,
|
||||
"meta": meta,
|
||||
"createdAt": sub.CreatedAt.UTC(),
|
||||
"updatedAt": sub.UpdatedAt.UTC(),
|
||||
}
|
||||
|
||||
if sub.CancelledAt != nil {
|
||||
|
||||
@ -554,6 +554,10 @@ func (s *postgresStore) UpsertSubscription(ctx context.Context, subscription *Su
|
||||
if externalID == "" {
|
||||
return errors.New("external id is required")
|
||||
}
|
||||
if strings.TrimSpace(subscription.PaymentMethod) == "" {
|
||||
subscription.PaymentMethod = strings.TrimSpace(subscription.Provider)
|
||||
}
|
||||
subscription.PaymentQRCode = strings.TrimSpace(subscription.PaymentQRCode)
|
||||
|
||||
encodedMeta, err := json.Marshal(subscription.Meta)
|
||||
if err != nil {
|
||||
@ -565,13 +569,15 @@ func (s *postgresStore) UpsertSubscription(ctx context.Context, subscription *Su
|
||||
cancelledAt = subscription.CancelledAt.UTC()
|
||||
}
|
||||
|
||||
const query = `INSERT INTO subscriptions (user_uuid, provider, kind, plan_id, external_id, status, meta, cancelled_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, '{}'::jsonb), $8)
|
||||
const query = `INSERT INTO subscriptions (user_uuid, provider, payment_method, kind, plan_id, external_id, status, payment_qr, meta, cancelled_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, '{}'::jsonb), $10)
|
||||
ON CONFLICT (user_uuid, external_id) DO UPDATE SET
|
||||
provider = EXCLUDED.provider,
|
||||
payment_method = EXCLUDED.payment_method,
|
||||
kind = EXCLUDED.kind,
|
||||
plan_id = EXCLUDED.plan_id,
|
||||
status = EXCLUDED.status,
|
||||
payment_qr = EXCLUDED.payment_qr,
|
||||
meta = EXCLUDED.meta,
|
||||
cancelled_at = EXCLUDED.cancelled_at,
|
||||
updated_at = now()
|
||||
@ -589,10 +595,12 @@ RETURNING uuid, created_at, updated_at, cancelled_at`
|
||||
query,
|
||||
normalizedUserID,
|
||||
strings.TrimSpace(subscription.Provider),
|
||||
strings.TrimSpace(subscription.PaymentMethod),
|
||||
strings.TrimSpace(subscription.Kind),
|
||||
strings.TrimSpace(subscription.PlanID),
|
||||
externalID,
|
||||
strings.TrimSpace(subscription.Status),
|
||||
strings.TrimSpace(subscription.PaymentQRCode),
|
||||
encodedMeta,
|
||||
cancelledAt,
|
||||
).Scan(&idValue, &createdAt, &updatedAt, &cancelled)
|
||||
@ -628,7 +636,7 @@ func (s *postgresStore) ListSubscriptionsByUser(ctx context.Context, userID stri
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
const query = `SELECT uuid, user_uuid, provider, kind, plan_id, external_id, status, meta, created_at, updated_at, cancelled_at
|
||||
const query = `SELECT uuid, user_uuid, provider, payment_method, kind, plan_id, external_id, status, payment_qr, meta, created_at, updated_at, cancelled_at
|
||||
FROM subscriptions WHERE user_uuid = $1 ORDER BY created_at DESC`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, normalizedUserID)
|
||||
@ -640,18 +648,20 @@ FROM subscriptions WHERE user_uuid = $1 ORDER BY created_at DESC`
|
||||
var subs []Subscription
|
||||
for rows.Next() {
|
||||
var (
|
||||
idValue any
|
||||
provider string
|
||||
kind string
|
||||
planID sql.NullString
|
||||
externalID string
|
||||
status string
|
||||
metaBytes []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
cancelled sql.NullTime
|
||||
idValue any
|
||||
provider string
|
||||
paymentMethod string
|
||||
kind string
|
||||
planID sql.NullString
|
||||
externalID string
|
||||
status string
|
||||
paymentQR sql.NullString
|
||||
metaBytes []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
cancelled sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&idValue, &normalizedUserID, &provider, &kind, &planID, &externalID, &status, &metaBytes, &createdAt, &updatedAt, &cancelled); err != nil {
|
||||
if err := rows.Scan(&idValue, &normalizedUserID, &provider, &paymentMethod, &kind, &planID, &externalID, &status, &paymentQR, &metaBytes, &createdAt, &updatedAt, &cancelled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -666,16 +676,18 @@ FROM subscriptions WHERE user_uuid = $1 ORDER BY created_at DESC`
|
||||
}
|
||||
|
||||
sub := Subscription{
|
||||
ID: identifier,
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
Kind: kind,
|
||||
PlanID: planID.String,
|
||||
ExternalID: externalID,
|
||||
Status: status,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
ID: identifier,
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
PaymentMethod: paymentMethod,
|
||||
PaymentQRCode: paymentQR.String,
|
||||
Kind: kind,
|
||||
PlanID: planID.String,
|
||||
ExternalID: externalID,
|
||||
Status: status,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
}
|
||||
if cancelled.Valid {
|
||||
sub.CancelledAt = &cancelled.Time
|
||||
@ -706,26 +718,30 @@ func (s *postgresStore) CancelSubscription(ctx context.Context, userID, external
|
||||
const query = `UPDATE subscriptions
|
||||
SET status = 'cancelled', cancelled_at = $3, updated_at = now()
|
||||
WHERE user_uuid = $1 AND external_id = $2
|
||||
RETURNING uuid, provider, kind, plan_id, status, meta, created_at, updated_at, cancelled_at`
|
||||
RETURNING uuid, provider, payment_method, kind, plan_id, status, payment_qr, meta, created_at, updated_at, cancelled_at`
|
||||
|
||||
var (
|
||||
idValue any
|
||||
provider string
|
||||
kind string
|
||||
planID sql.NullString
|
||||
status string
|
||||
metaBytes []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
cancelled sql.NullTime
|
||||
idValue any
|
||||
provider string
|
||||
paymentMethod string
|
||||
kind string
|
||||
planID sql.NullString
|
||||
status string
|
||||
paymentQR sql.NullString
|
||||
metaBytes []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
cancelled sql.NullTime
|
||||
)
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, normalizedUserID, key, cancelledAt.UTC()).Scan(
|
||||
&idValue,
|
||||
&provider,
|
||||
&paymentMethod,
|
||||
&kind,
|
||||
&planID,
|
||||
&status,
|
||||
&paymentQR,
|
||||
&metaBytes,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
@ -749,16 +765,18 @@ RETURNING uuid, provider, kind, plan_id, status, meta, created_at, updated_at, c
|
||||
}
|
||||
|
||||
sub := &Subscription{
|
||||
ID: identifier,
|
||||
UserID: normalizedUserID,
|
||||
Provider: provider,
|
||||
Kind: kind,
|
||||
PlanID: planID.String,
|
||||
ExternalID: key,
|
||||
Status: status,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
ID: identifier,
|
||||
UserID: normalizedUserID,
|
||||
Provider: provider,
|
||||
PaymentMethod: paymentMethod,
|
||||
PaymentQRCode: paymentQR.String,
|
||||
Kind: kind,
|
||||
PlanID: planID.String,
|
||||
ExternalID: key,
|
||||
Status: status,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
}
|
||||
if cancelled.Valid {
|
||||
sub.CancelledAt = &cancelled.Time
|
||||
|
||||
@ -32,17 +32,19 @@ type User struct {
|
||||
|
||||
// Subscription represents a recurring or usage-based billing relationship.
|
||||
type Subscription struct {
|
||||
ID string
|
||||
UserID string
|
||||
Provider string
|
||||
Kind string
|
||||
PlanID string
|
||||
ExternalID string
|
||||
Status string
|
||||
Meta map[string]any
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CancelledAt *time.Time
|
||||
ID string
|
||||
UserID string
|
||||
Provider string
|
||||
PaymentMethod string
|
||||
PaymentQRCode string
|
||||
Kind string
|
||||
PlanID string
|
||||
ExternalID string
|
||||
Status string
|
||||
Meta map[string]any
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CancelledAt *time.Time
|
||||
}
|
||||
|
||||
// Store provides persistence operations for users.
|
||||
@ -306,6 +308,10 @@ func (s *memoryStore) UpsertSubscription(ctx context.Context, subscription *Subs
|
||||
if key == "" {
|
||||
return errors.New("external id is required")
|
||||
}
|
||||
if strings.TrimSpace(subscription.PaymentMethod) == "" {
|
||||
subscription.PaymentMethod = strings.TrimSpace(subscription.Provider)
|
||||
}
|
||||
subscription.PaymentQRCode = strings.TrimSpace(subscription.PaymentQRCode)
|
||||
|
||||
now := time.Now().UTC()
|
||||
stored, exists := userSubs[key]
|
||||
@ -315,6 +321,8 @@ func (s *memoryStore) UpsertSubscription(ctx context.Context, subscription *Subs
|
||||
}
|
||||
|
||||
stored.Provider = strings.TrimSpace(subscription.Provider)
|
||||
stored.PaymentMethod = strings.TrimSpace(subscription.PaymentMethod)
|
||||
stored.PaymentQRCode = strings.TrimSpace(subscription.PaymentQRCode)
|
||||
stored.Kind = strings.TrimSpace(subscription.Kind)
|
||||
stored.PlanID = strings.TrimSpace(subscription.PlanID)
|
||||
stored.Status = strings.TrimSpace(subscription.Status)
|
||||
|
||||
@ -118,10 +118,12 @@ CREATE TABLE public.subscriptions (
|
||||
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_uuid UUID NOT NULL REFERENCES public.users(uuid) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
payment_method TEXT NOT NULL DEFAULT 'paypal',
|
||||
kind TEXT NOT NULL DEFAULT 'subscription',
|
||||
plan_id TEXT,
|
||||
external_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
payment_qr TEXT,
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
115
dashboard/src/components/billing/CryptoBillingWidget.tsx
Normal file
115
dashboard/src/components/billing/CryptoBillingWidget.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import type { BillingPaymentMethod } from '@modules/products/registry'
|
||||
|
||||
type CryptoBillingWidgetProps = {
|
||||
method: BillingPaymentMethod
|
||||
planName: string
|
||||
planId?: string
|
||||
kind: 'paygo' | 'subscription'
|
||||
productSlug?: string
|
||||
onRecord?: (payload: {
|
||||
externalId: string
|
||||
status?: string
|
||||
paymentQr?: string
|
||||
meta?: Record<string, unknown>
|
||||
}) => void
|
||||
}
|
||||
|
||||
export default function CryptoBillingWidget({
|
||||
method,
|
||||
planName,
|
||||
planId,
|
||||
kind,
|
||||
productSlug,
|
||||
onRecord,
|
||||
}: CryptoBillingWidgetProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const label = useMemo(() => method.label || method.type.toUpperCase(), [method.label, method.type])
|
||||
const address = method.address?.trim()
|
||||
const network = method.network?.trim()
|
||||
const qrCode = method.qrCode?.trim()
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!address || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(address)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.warn('Failed to copy payment address', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecord = () => {
|
||||
if (!onRecord) return
|
||||
const externalId = `${method.type}-${planId || kind}-${Date.now()}`
|
||||
onRecord({
|
||||
externalId,
|
||||
status: 'pending',
|
||||
paymentQr: qrCode,
|
||||
meta: {
|
||||
paymentMethod: method.type,
|
||||
address,
|
||||
network,
|
||||
instructions: method.instructions,
|
||||
planName,
|
||||
productSlug,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-brand">{label}</p>
|
||||
{network ? <p className="text-xs text-slate-600">网络 / Network: {network}</p> : null}
|
||||
</div>
|
||||
{qrCode ? <span className="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">扫码支付</span> : null}
|
||||
</div>
|
||||
|
||||
{method.instructions ? (
|
||||
<p className="mt-2 text-sm text-slate-700">{method.instructions}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-700">扫码或复制地址完成支付后,点击同步到账户。</p>
|
||||
)}
|
||||
|
||||
{address ? (
|
||||
<div className="mt-3 rounded-lg bg-white p-3 text-xs font-mono text-slate-800">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate" title={address}>
|
||||
{address}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white hover:bg-slate-800"
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{qrCode ? (
|
||||
<div className="mt-3 rounded-lg bg-white p-3">
|
||||
<img src={qrCode} alt={`${label} QR`} className="mx-auto h-36 w-36 object-contain" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRecord}
|
||||
className="inline-flex items-center justify-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-dark"
|
||||
>
|
||||
同步扫码订单
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,7 +4,8 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { PayPalPayGoButton, PayPalSubscriptionButton } from '@components/billing/PayPalButtons'
|
||||
import type { ProductConfig } from '@modules/products/registry'
|
||||
import CryptoBillingWidget from '@components/billing/CryptoBillingWidget'
|
||||
import type { BillingPaymentMethod, ProductConfig } from '@modules/products/registry'
|
||||
|
||||
function resolveClientId(planClientId?: string) {
|
||||
if (planClientId && planClientId.trim().length > 0) {
|
||||
@ -44,6 +45,9 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
|
||||
kind: string
|
||||
planId?: string
|
||||
status: string
|
||||
provider?: string
|
||||
paymentMethod?: string
|
||||
paymentQr?: string
|
||||
meta?: Record<string, unknown>
|
||||
}) => {
|
||||
try {
|
||||
@ -54,7 +58,9 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider: 'paypal',
|
||||
provider: payload.provider || 'paypal',
|
||||
paymentMethod: payload.paymentMethod || payload.provider || 'paypal',
|
||||
paymentQr: payload.paymentQr,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
@ -95,15 +101,15 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '直接在产品页面完成 PayPal 支付,记录会同步到账户中心。'
|
||||
: 'Complete PayPal checkout directly on the product page and keep records in your account.'}
|
||||
? '直接在产品页面完成 PayPal / 以太坊 / USDT 支付与扫码,记录会同步到账户中心。'
|
||||
: 'Complete PayPal, Ethereum, or USDT checkout with QR support and keep records in your account.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700">
|
||||
{clientId
|
||||
? lang === 'zh'
|
||||
? '使用 PayPal 安全结算'
|
||||
: 'Secure checkout with PayPal'
|
||||
? '使用 PayPal / 以太坊 / USDT 安全结算与扫码'
|
||||
: 'PayPal, Ethereum, and USDT checkout with QR support'
|
||||
: lang === 'zh'
|
||||
? '尚未配置 PayPal Client ID'
|
||||
: 'PayPal Client ID is not configured'}
|
||||
@ -144,10 +150,46 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
|
||||
kind: 'paygo',
|
||||
planId: paygo.planId,
|
||||
status: 'active',
|
||||
provider: 'paypal',
|
||||
paymentMethod: 'paypal',
|
||||
meta: { ...paygo.meta, product: config.slug, paypal: data },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{paygo.paymentMethods?.length ? (
|
||||
<div className="mt-5 space-y-2">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{lang === 'zh'
|
||||
? '支持 PayPal / 以太坊 / USDT 扫码记录:'
|
||||
: 'QR checkout for PayPal, Ethereum, and USDT:'}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{paygo.paymentMethods.map((method: BillingPaymentMethod) => (
|
||||
<CryptoBillingWidget
|
||||
key={`${paygo.planId}-${method.type}`}
|
||||
method={method}
|
||||
planId={paygo.planId}
|
||||
planName={paygo.name}
|
||||
kind="paygo"
|
||||
productSlug={config.slug}
|
||||
onRecord={(details) =>
|
||||
handleSync({
|
||||
externalId: details.externalId,
|
||||
kind: 'paygo',
|
||||
planId: paygo.planId,
|
||||
status: details.status || 'pending',
|
||||
provider: method.type,
|
||||
paymentMethod: method.type,
|
||||
paymentQr: details.paymentQr,
|
||||
meta: { ...paygo.meta, ...details.meta, product: config.slug },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -183,10 +225,46 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
|
||||
kind: 'subscription',
|
||||
planId: saas.planId,
|
||||
status: 'active',
|
||||
provider: 'paypal',
|
||||
paymentMethod: 'paypal',
|
||||
meta: { ...saas.meta, product: config.slug, paypal: data },
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{saas.paymentMethods?.length ? (
|
||||
<div className="mt-5 space-y-2">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{lang === 'zh'
|
||||
? '订阅也可通过 PayPal / 以太坊 / USDT 扫码:'
|
||||
: 'Subscriptions via PayPal, Ethereum, or USDT QR codes:'}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{saas.paymentMethods.map((method: BillingPaymentMethod) => (
|
||||
<CryptoBillingWidget
|
||||
key={`${saas.planId}-${method.type}`}
|
||||
method={method}
|
||||
planId={saas.planId}
|
||||
planName={saas.name}
|
||||
kind="subscription"
|
||||
productSlug={config.slug}
|
||||
onRecord={(details) =>
|
||||
handleSync({
|
||||
externalId: details.externalId,
|
||||
kind: 'subscription',
|
||||
planId: saas.planId,
|
||||
status: details.status || 'pending',
|
||||
provider: method.type,
|
||||
paymentMethod: method.type,
|
||||
paymentQr: details.paymentQr,
|
||||
meta: { ...saas.meta, ...details.meta, product: config.slug },
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -19,6 +19,8 @@ type SubscriptionRecord = {
|
||||
kind?: string
|
||||
planId?: string
|
||||
status: string
|
||||
paymentMethod?: string
|
||||
paymentQr?: string
|
||||
externalId: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
@ -84,7 +86,9 @@ export default function SubscriptionPanel() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--color-heading)]">订阅与计费</h2>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">查看你通过 PayPal 触发的 Pay-as-you-go 与 SaaS 订阅。</p>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
查看你通过 PayPal / 以太坊 / USDT(含二维码扫码)的 Pay-as-you-go 与 SaaS 订阅,试用也会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -105,6 +109,9 @@ export default function SubscriptionPanel() {
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">{record.provider}</p>
|
||||
<h3 className="text-base font-semibold text-[var(--color-text)]">{record.kind ?? 'subscription'}</h3>
|
||||
{record.paymentMethod ? (
|
||||
<p className="text-xs text-[var(--color-text-subtle)]">付款方式:{record.paymentMethod}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${record.status === 'cancelled' ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}
|
||||
@ -129,13 +136,40 @@ export default function SubscriptionPanel() {
|
||||
<dt>Updated</dt>
|
||||
<dd className="text-[var(--color-text)]">{formatDate(record.updatedAt)}</dd>
|
||||
</div>
|
||||
{typeof record.meta?.startsAt === 'string' ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt>Starts</dt>
|
||||
<dd className="text-[var(--color-text)]">{formatDate(record.meta?.startsAt as string)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof record.meta?.expiresAt === 'string' ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt>Expires</dt>
|
||||
<dd className="text-[var(--color-text)]">{formatDate(record.meta?.expiresAt as string)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{record.cancelledAt ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt>Cancelled</dt>
|
||||
<dd className="text-[var(--color-text)]">{formatDate(record.cancelledAt)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{record.meta?.note ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt>备注</dt>
|
||||
<dd className="text-[var(--color-text)]">{String(record.meta?.note)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{record.paymentQr ? (
|
||||
<div className="mt-3 rounded-lg bg-white p-3">
|
||||
<img
|
||||
src={record.paymentQr}
|
||||
alt={`QR for ${record.externalId}`}
|
||||
className="mx-auto h-28 w-28 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import Card from '../components/Card'
|
||||
import SubscriptionPanel from '../account/SubscriptionPanel'
|
||||
|
||||
export default function UserCenterSubscriptionRoute() {
|
||||
return (
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Subscription</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Manage subscriptions and invoicing rules.</p>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">订阅与支付</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
支持 PayPal、以太坊与 USDT 的订阅与按量计费,扫码或直连支付后都可同步到账户中心。
|
||||
</p>
|
||||
</Card>
|
||||
<SubscriptionPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -37,6 +37,15 @@ export type ProductConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
export type BillingPaymentMethod = {
|
||||
type: 'paypal' | 'ethereum' | 'usdt'
|
||||
label?: string
|
||||
address?: string
|
||||
network?: string
|
||||
qrCode?: string
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
export type BillingPlan = {
|
||||
name: string
|
||||
description?: string
|
||||
@ -46,6 +55,7 @@ export type BillingPlan = {
|
||||
planId?: string
|
||||
clientId?: string
|
||||
meta?: Record<string, unknown>
|
||||
paymentMethods?: BillingPaymentMethod[]
|
||||
}
|
||||
|
||||
export const PRODUCT_LIST: ProductConfig[] = [xstream, xscopehub, xcloudflow]
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const sharedPaymentMethods = [
|
||||
{
|
||||
type: 'paypal',
|
||||
label: 'PayPal 扫码',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol',
|
||||
instructions: '使用 PayPal 客户端扫码或打开二维码链接完成支付。',
|
||||
},
|
||||
{
|
||||
type: 'ethereum',
|
||||
label: '以太坊 / ETH',
|
||||
network: 'ERC20',
|
||||
address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
instructions: '完成链上转账后,点击同步扫码订单将记录存入账户。',
|
||||
},
|
||||
{
|
||||
type: 'usdt',
|
||||
label: 'USDT',
|
||||
network: 'TRC20',
|
||||
address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
instructions: '支持 USDT-TRC20,扫码完成后可同步到账单。',
|
||||
},
|
||||
]
|
||||
|
||||
const xcloudflow: ProductConfig = {
|
||||
slug: 'xcloudflow',
|
||||
name: 'XCloudFlow',
|
||||
@ -58,6 +86,7 @@ const xcloudflow: ProductConfig = {
|
||||
currency: 'USD',
|
||||
planId: 'XCLOUDFLOW-PAYGO',
|
||||
meta: { tier: 'usage', product: 'xcloudflow' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
saas: {
|
||||
name: 'CloudFlow SaaS',
|
||||
@ -67,6 +96,7 @@ const xcloudflow: ProductConfig = {
|
||||
interval: 'month',
|
||||
planId: 'XCLOUDFLOW-SUBSCRIPTION',
|
||||
meta: { tier: 'team', product: 'xcloudflow' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const sharedPaymentMethods = [
|
||||
{
|
||||
type: 'paypal',
|
||||
label: 'PayPal 扫码',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol',
|
||||
instructions: '打开 PayPal App 扫码或跳转二维码链接完成支付。',
|
||||
},
|
||||
{
|
||||
type: 'ethereum',
|
||||
label: '以太坊 / ETH',
|
||||
network: 'ERC20',
|
||||
address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
instructions: '支持 ETH/USDT ERC20 转账,付款后在账户中心同步扫码订单。',
|
||||
},
|
||||
{
|
||||
type: 'usdt',
|
||||
label: 'USDT',
|
||||
network: 'TRC20',
|
||||
address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
instructions: 'USDT-TRC20 扫码转账完成后,点击同步记录到账户。',
|
||||
},
|
||||
]
|
||||
|
||||
const xscopehub: ProductConfig = {
|
||||
slug: 'xscopehub',
|
||||
name: 'XScopeHub',
|
||||
@ -58,6 +86,7 @@ const xscopehub: ProductConfig = {
|
||||
currency: 'USD',
|
||||
planId: 'XSCOPEHUB-PAYGO',
|
||||
meta: { tier: 'usage', product: 'xscopehub' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
saas: {
|
||||
name: 'ScopeHub SaaS',
|
||||
@ -67,6 +96,7 @@ const xscopehub: ProductConfig = {
|
||||
interval: 'month',
|
||||
planId: 'XSCOPEHUB-SUBSCRIPTION',
|
||||
meta: { tier: 'growth', product: 'xscopehub' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,5 +1,33 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const sharedPaymentMethods = [
|
||||
{
|
||||
type: 'paypal',
|
||||
label: 'PayPal 扫码',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=https://www.paypal.com/paypalme/xcontrol',
|
||||
instructions: '使用 PayPal App 扫码,或在浏览器打开二维码链接完成支付。',
|
||||
},
|
||||
{
|
||||
type: 'ethereum',
|
||||
label: '以太坊 / ETH',
|
||||
network: 'ERC20',
|
||||
address: '0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=ethereum:0x8ba1f109551bD432803012645Ac136ddd64DBA72',
|
||||
instructions: '转账后点击“同步扫码订单”即可在账户中心看到记录。',
|
||||
},
|
||||
{
|
||||
type: 'usdt',
|
||||
label: 'USDT',
|
||||
network: 'TRC20',
|
||||
address: 'TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
qrCode:
|
||||
'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=usdt:TRC20:TK9p9oxKGVfYB1D6UcqSgnZJx1f3w3Zz7B',
|
||||
instructions: '支持 USDT-TRC20 扫码支付,完成后同步到订单记录。',
|
||||
},
|
||||
]
|
||||
|
||||
const xstream: ProductConfig = {
|
||||
slug: 'xstream',
|
||||
name: 'Xstream',
|
||||
@ -58,6 +86,7 @@ const xstream: ProductConfig = {
|
||||
currency: 'USD',
|
||||
planId: 'XSTREAM-PAYGO',
|
||||
meta: { tier: 'usage', product: 'xstream' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
saas: {
|
||||
name: 'Xstream Pro',
|
||||
@ -67,6 +96,7 @@ const xstream: ProductConfig = {
|
||||
interval: 'month',
|
||||
planId: 'XSTREAM-SUBSCRIPTION',
|
||||
meta: { tier: 'pro', product: 'xstream' },
|
||||
paymentMethods: sharedPaymentMethods,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user