Add crypto billing options and trial subscriptions (#680)

This commit is contained in:
shenlan 2025-11-21 19:20:51 +08:00 committed by GitHub
parent a1afa0d829
commit dda0dbbcd4
12 changed files with 481 additions and 89 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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)

View File

@ -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(),

View 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>
)
}

View File

@ -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}

View File

@ -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"

View File

@ -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>
)
}

View File

@ -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]

View File

@ -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,
},
},
}

View File

@ -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,
},
},
}

View File

@ -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,
},
},
}