From dda0dbbcd4a6ef4ff4afadc0c03e5add9ae3bd15 Mon Sep 17 00:00:00 2001 From: shenlan Date: Fri, 21 Nov 2025 19:20:51 +0800 Subject: [PATCH] Add crypto billing options and trial subscriptions (#680) --- account/api/api.go | 77 ++++++++---- account/internal/store/postgres.go | 106 +++++++++------- account/internal/store/store.go | 30 +++-- account/sql/schema.sql | 2 + .../billing/CryptoBillingWidget.tsx | 115 ++++++++++++++++++ .../marketing/ProductBillingActions.tsx | 90 +++++++++++++- .../user-center/account/SubscriptionPanel.tsx | 36 +++++- .../user-center/routes/subscription.tsx | 14 ++- dashboard/src/modules/products/registry.ts | 10 ++ dashboard/src/modules/products/xcloudflow.ts | 30 +++++ dashboard/src/modules/products/xscopehub.ts | 30 +++++ dashboard/src/modules/products/xstream.ts | 30 +++++ 12 files changed, 481 insertions(+), 89 deletions(-) create mode 100644 dashboard/src/components/billing/CryptoBillingWidget.tsx diff --git a/account/api/api.go b/account/api/api.go index f293b9b..c92de1d 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -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 { diff --git a/account/internal/store/postgres.go b/account/internal/store/postgres.go index 35cf92a..44f8aa5 100644 --- a/account/internal/store/postgres.go +++ b/account/internal/store/postgres.go @@ -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 diff --git a/account/internal/store/store.go b/account/internal/store/store.go index f78b776..2430f02 100644 --- a/account/internal/store/store.go +++ b/account/internal/store/store.go @@ -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) diff --git a/account/sql/schema.sql b/account/sql/schema.sql index fd05604..659b019 100644 --- a/account/sql/schema.sql +++ b/account/sql/schema.sql @@ -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(), diff --git a/dashboard/src/components/billing/CryptoBillingWidget.tsx b/dashboard/src/components/billing/CryptoBillingWidget.tsx new file mode 100644 index 0000000..ea920cd --- /dev/null +++ b/dashboard/src/components/billing/CryptoBillingWidget.tsx @@ -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 + }) => 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 ( +
+
+
+

{label}

+ {network ?

网络 / Network: {network}

: null} +
+ {qrCode ? 扫码支付 : null} +
+ + {method.instructions ? ( +

{method.instructions}

+ ) : ( +

扫码或复制地址完成支付后,点击同步到账户。

+ )} + + {address ? ( +
+
+ + {address} + + +
+
+ ) : null} + + {qrCode ? ( +
+ {`${label} +
+ ) : null} + +
+ +
+
+ ) +} diff --git a/dashboard/src/components/marketing/ProductBillingActions.tsx b/dashboard/src/components/marketing/ProductBillingActions.tsx index d2bdaa4..23a86b1 100644 --- a/dashboard/src/components/marketing/ProductBillingActions.tsx +++ b/dashboard/src/components/marketing/ProductBillingActions.tsx @@ -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 }) => { 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

{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.'}

{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 ? ( +
+

+ {lang === 'zh' + ? '支持 PayPal / 以太坊 / USDT 扫码记录:' + : 'QR checkout for PayPal, Ethereum, and USDT:'} +

+
+ {paygo.paymentMethods.map((method: BillingPaymentMethod) => ( + + 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 }, + }) + } + /> + ))} +
+
+ ) : null}
) : 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 ? ( +
+

+ {lang === 'zh' + ? '订阅也可通过 PayPal / 以太坊 / USDT 扫码:' + : 'Subscriptions via PayPal, Ethereum, or USDT QR codes:'} +

+
+ {saas.paymentMethods.map((method: BillingPaymentMethod) => ( + + 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 }, + }) + } + /> + ))} +
+
+ ) : null} ) : null} diff --git a/dashboard/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx b/dashboard/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx index 7f6a1f3..590bf85 100644 --- a/dashboard/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx +++ b/dashboard/src/modules/extensions/builtin/user-center/account/SubscriptionPanel.tsx @@ -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() {

订阅与计费

-

查看你通过 PayPal 触发的 Pay-as-you-go 与 SaaS 订阅。

+

+ 查看你通过 PayPal / 以太坊 / USDT(含二维码扫码)的 Pay-as-you-go 与 SaaS 订阅,试用也会出现在这里。 +

@@ -105,6 +109,9 @@ export default function SubscriptionPanel() {

{record.provider}

{record.kind ?? 'subscription'}

+ {record.paymentMethod ? ( +

付款方式:{record.paymentMethod}

+ ) : null}
Updated
{formatDate(record.updatedAt)}
+ {typeof record.meta?.startsAt === 'string' ? ( +
+
Starts
+
{formatDate(record.meta?.startsAt as string)}
+
+ ) : null} + {typeof record.meta?.expiresAt === 'string' ? ( +
+
Expires
+
{formatDate(record.meta?.expiresAt as string)}
+
+ ) : null} {record.cancelledAt ? (
Cancelled
{formatDate(record.cancelledAt)}
) : null} + {record.meta?.note ? ( +
+
备注
+
{String(record.meta?.note)}
+
+ ) : null} + {record.paymentQr ? ( +
+ {`QR +
+ ) : null}