Pairs with the backend structured payment errors (reason + metadata). The
frontend now maps reason codes to localized messages with metadata as
interpolation variables, and automatically localizes raw config-field names
(e.g. "certSerial" → "证书序列号") using the existing UI-label i18n
namespace.
- frontend/src/utils/apiError.ts
- extractApiErrorCode now prefers the string `reason` over the numeric HTTP
`code`; reason is granular enough to drive i18n lookup, HTTP code is not.
- New extractApiErrorMetadata to pull interpolation params off the error.
- New extractI18nErrorMessage(err, t, namespace, fallback): looks up
`<namespace>.<REASON>` in i18n and substitutes metadata. Before
substitution, `metadata.key` and `metadata.keys` (slash-joined) are
re-translated through `admin.settings.payment.field_<key>` so users see
"缺少必填项:证书序列号" instead of "缺少必填项:certSerial".
- frontend/src/i18n/locales/{zh,en}.ts
- Add payment.errors entries for every structured reason code returned by
the backend (PAYMENT_DISABLED, INVALID_AMOUNT, TOO_MANY_PENDING,
DAILY_LIMIT_EXCEEDED, NO_AVAILABLE_INSTANCE, PAYMENT_PROVIDER_MISCONFIGURED,
WXPAY_CONFIG_MISSING_KEY / INVALID_KEY_LENGTH / INVALID_KEY, NOT_FOUND,
FORBIDDEN, CONFLICT, INVALID_ORDER_TYPE, INVALID_STATUS,
BALANCE_NOT_ENOUGH, REFUND_AMOUNT_EXCEEDED, REFUND_FAILED, and more),
with placeholders for template variables.
- 13 payment-related Vue files
- Migrate catch-block error reporting from extractApiErrorMessage to
extractI18nErrorMessage(err, t, 'payment.errors', fallback).
- Remove the ad-hoc paymentErrorMap computed in SettingsView.vue, which the
new helper supersedes (it reads i18n directly via t).
- frontend/src/components/payment/providerConfig.ts
- wxpay: publicKey and publicKeyId are now required (was optional), matching
the pubkey-only verifier direction; certSerial is already required.
This PR is drop-in safe: reason-preferring extractApiErrorCode is backward
compatible with callers that pass their own i18nMap, and error codes missing
from i18n fall back to the existing message-based path.
Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
Accounts use soft-delete (setting deleted_at), so PostgreSQL's
ON DELETE CASCADE on scheduled_test_plans.account_id never fires.
Add plan deletion to the existing account deletion transaction
to ensure atomicity.
ClosesWei-Shaw/sub2api#1728
Add quota exceeded check to IsSchedulable() and refactor
shouldClearStickySession to delegate to IsSchedulable(), eliminating
duplicated logic and fixing missed overload/rate-limit/expired checks.
Frontend displays quota exceeded status independently via quota fields.
Admin GET /api/v1/admin/payment/providers previously returned every
config value — including privateKey / apiV3Key / secretKey etc. —
verbatim. Any future XSS on the admin UI would hand attackers the
full set of production payment credentials, and the plaintext values
sat unnecessarily in browser memory for every operator.
Treat those fields as write-only from the admin surface:
- decryptAndMaskConfig() strips sensitive keys from the GET response.
The authoritative list is an explicit per-provider registry that
mirrors the frontend's PROVIDER_CONFIG_FIELDS sensitive flag:
alipay → privateKey, publicKey, alipayPublicKey
wxpay → privateKey, apiV3Key, publicKey
stripe → secretKey, webhookSecret (publishableKey stays plain)
easypay → pkey
Payment runtime still reads the full config via decryptConfig, so
nothing at the gateway changes.
- mergeConfig() treats an empty value for a sensitive key as "leave
unchanged" — the admin UI omits unchanged secrets so operators can
tweak non-sensitive settings without re-entering credentials.
- Admin dialog (PaymentProviderDialog.vue):
* secret inputs get autocomplete="new-password", data-1p-ignore,
data-lpignore and data-bwignore so password managers do not
offer to save provider credentials
* in edit mode the required-field check skips sensitive fields
(empty is the "keep existing" signal) and the placeholder shows
"leave empty to keep" instead of the default example value
* create mode still requires every non-optional field, including
secrets, since there is nothing to preserve
- Unit test renamed to TestIsSensitiveProviderConfigField, covers
the per-provider registry and specifically asserts that Stripe's
publishableKey is NOT treated as a secret.
The native Alipay provider previously tried to embed the payment page
URL into a QR code on the client — the URL is not a scannable payload
so the QR never worked. Merchants also hit a H5 detection mismatch
whenever the backend UA sniffer missed iPadOS 13+ or embedded browsers,
and the popup window was too small for Alipay's standard checkout
layout (QR + account-login panel on the right), forcing the user to
scroll horizontally and vertically.
Changes:
Backend
- alipay.go: drop QR-on-URL path. Use redirect-only flow —
alipay.trade.page.pay for PC (returns a gateway URL the browser
opens in a new window) and alipay.trade.wap.pay for H5 (returns a
URL the browser jumps to). Both flows produce pages on
openapi.alipaydev.com / excashier.alipay.com; the client never
renders a QR itself.
- payment_handler.go: add optional is_mobile bool to
CreateOrderRequest so the frontend can declare the device
explicitly. Server still falls back to UA sniffing when absent.
Frontend
- types/payment.ts, PaymentView.vue: declare is_mobile in
CreateOrderRequest and pass the computed isMobileDevice() value.
- providerConfig.ts: replace the two fixed POPUP_WINDOW_FEATURES
constants with getPaymentPopupFeatures(), which prefers 1250×900
(Alipay's checkout footprint), clamps to window.screen.avail* and
centers the popup so it never overflows on smaller laptops.
- PaymentQRDialog.vue, PaymentStatusPanel.vue, StripePaymentInline.vue,
PaymentView.vue: use the new helper at all popup call sites.
Restructure the EasyPay recommendation block to present two options side
by side so users can pick by funding channel and settlement currency:
- Domestic / CNY — ZPay: official Alipay/WeChat API with 1.6% fee and
T+1 automatic settlement (existing recommendation, expanded with fee
and settlement details).
- International / USDT or USD — Kyren Topup (https://kyren.top): global
payment stack supporting WeChat Pay and Alipay with local-currency
checkout, USD settlement, and USDT/USD withdrawal. Fees: WeChat 2%,
Alipay 2.5%, withdrawal 0.1% ($40 min / $150 max). Fills the gap for
users who cannot use domestic Chinese channels or tolerate Stripe's
6%+ fees.
Both recommendations share a single disclaimer at the end. The Chinese
heading uses "易支付" while the English one keeps "EasyPay".
Chrome's password manager matched the apikey-type account's Base URL + API Key
inputs as a login form and autofilled the last saved password by domain, so
editing a Gemini account could overwrite its apikey with a Claude key that
shared the same Base URL. Add autocomplete="new-password" plus data-*-ignore
attributes for 1Password / LastPass / Bitwarden to opt the field out of every
major password manager's autofill.
Subscription-mode billing was consuming quota at TotalCost (raw) instead of
ActualCost (TotalCost * RateMultiplier), so per-group rate multipliers —
including free subscriptions (multiplier = 0) — were silently ignored.
Switch the three subscription cost writes in buildUsageBillingCommand,
finalizePostUsageBilling, and the legacy postUsageBilling fallback to
ActualCost, and add a table-driven test covering 2x / 0.5x / free multipliers
plus a balance-mode regression check.
Without TOTP_ENCRYPTION_KEY, saved payment configs were lost on restart because
the AES round-trip failed silently. Write new records as plaintext JSON; read
path tries JSON first, falls back to legacy AES decrypt when a key is present,
and treats unreadable values as empty so admins can re-enter them via the UI.
Consolidates 9 call sites of resolveUpstreamResponseReadLimit + readUpstreamResponseBodyLimited + ErrUpstreamResponseBodyTooLarge error handling into a single ReadUpstreamResponseBody function with TooLargeWriter callback for API-format-specific error responses (Anthropic, OpenAI, countTokens).
Replace dead EventSource variable with AbortController to enable
cancelling fetch streams. Remove close-button disable during connecting
status so users can dismiss the dialog at any time.
Product name (e.g. "快代码科技工作室 100 元") should show the user's
original recharge amount (limitAmount), not the fee-inclusive pay amount.
The gateway receives payAmount separately for actual charging.
- Fix gofmt alignment in setting_handler.go, settings.go, payment_config_service.go
- Add payment_balance_recharge_multiplier and payment_recharge_fee_rate
to API contract test expected JSON
- Show base amount (充值金额) as first line
- Show fee amount with percentage when fee_rate > 0
- Show pay_amount (实付金额) in bold primary color
- Show credited amount (到账金额) when different from pay_amount
- Compute baseAmount and feeAmount from backend order data
Backend:
- Use cfg.RechargeFeeRate in order creation instead of hardcoded 0
- Remove dead getFeeRate stub method
- All amounts computed server-side: order_amount, pay_amount, fee_rate
Frontend - PaymentView:
- Read recharge_fee_rate from checkout-info API (not per-method)
- Show fee breakdown only when fee_rate > 0
- Show credited amount only when multiplier ≠ 1
Frontend - Order display (user + admin):
- Fix fee_rate * 100 bug (fee_rate is already a percentage)
- OrderTable: show pay_amount as primary, fee/credited as sub-lines
- AdminOrderDetail: full breakdown (base/fee/paid/credited)
- AdminRefundDialog: label "到账金额" for clarity
- PaymentResultView: show pay_amount with fee info
Types + i18n:
- Add recharge_fee_rate to CheckoutInfoResponse
- Add fee_rate to CreateOrderResult
- Add translations: creditedAmount, fee, baseAmount, includedInPayAmount
wire_gen.go was calling NewSchedulerCache(redisClient) but wire.go had
been updated to register ProvideSchedulerCache(redisClient, config),
which reads SnapshotMGetChunkSize and SnapshotWriteChunkSize from config.
Without this fix, those config values were silently ignored.
Extract repeated badge template (SVG icon + current/max display) into
a reusable CapacityBadge component. Reduces AccountCapacityCell from
~300 lines to ~180 lines with identical behavior.
1. PrepareRefund: block refund on provider instance lookup failure
instead of silently skipping permission check (medium severity)
2. UpdateProviderInstance: allow enabling refund_enabled and
allow_user_refund in the same request by checking req.RefundEnabled
value before falling back to DB read
3. ExecuteRefund: only revoke subscription on ErrAdjustWouldExpire,
abort on other errors (DB failure, not found) instead of
unconditionally revoking
Bug fixes:
- Detached context for GetAccountConcurrencyBatch (prevent all-zero on request cancel)
- Filter soft-deleted users in GetByGroupID
- Stripe CSP policy (allow Stripe.js in script-src and frame-src)
- WebSearch API key validation on save
- RECHARGING status in payment result success check
- Windows test fixes (logger Sync deadlock, config path escaping)
Feature enhancements:
- Webhook multi-instance dispatch (extractOutTradeNo + GetWebhookProvider)
- EasyPay mobile H5 payment (device param + PayURL2)
- SSE error propagation in WebSearch emulation
- AccountStatsCost DTO field for admin usage logs
- Plans sort by sort_order instead of created_at
- UsageMapHook for streaming response usage data
- apicompat Instructions field passthrough
- EffectiveLoadFactor for ops concurrency/metrics
- Usage billing RETURNING balance for notify system
- BulkUpdate mixed channel warning with details
- println to slog migration in auth cache
- Wire ProviderSet cleanup
- CI cache-dependency-path optimization
Frontend:
- Refund eligibility check per provider (canRequestRefund)
- Plan sort_order editing
- Dead code cleanup (simulate_claude_max, client_affinity)
- GroupsView platform switch guard
- channels features_config API type
- UsageView account_stats_cost export
allow_user_refund:
- Add allow_user_refund field to PaymentProviderInstance ent schema
- Migration 103: ALTER TABLE payment_provider_instances ADD COLUMN
- Cascade logic: disabling refund_enabled auto-disables allow_user_refund
- User refund validation: check provider instance allows user refund
- Admin refund validation: check provider instance allows admin refund
- Subscription refund: deduct days on refund, rollback on failure
- New endpoint: GET /payment/orders/refund-eligible-providers
- Frontend: ToggleSwitch in ProviderCard/Dialog, cascade in SettingsView
Wildcard matching:
- Change findPricingForModel from "longest prefix wins" to "config order
priority (first match wins)", aligning with channel service behavior
- Fix errcheck: handle Write/Encode return values in brave_test.go
- Fix errcheck: defer resp.Body.Close() with _ assignment in tavily.go
- Fix gofmt: payment.go, channel.go, payment_config_providers.go
- Fix unused: remove dead decodeURLValue in easypay.go
- Restore shouldFallbackGeminiModel function (deleted during cherry-pick)
- Add missing balanceNotifyService param to NewGatewayService in test
- Fix platform default test expectation (empty stays empty)
- Fix wildcard pricing test (longest prefix wins, not config order)
- Fix subscription group test (SUBSCRIPTION_REPOSITORY_UNAVAILABLE)
Checkout page aggregates Stripe sub-types (card,link,alipay,wxpay) under
"stripe", but SelectInstance matched against supported_types literally,
which doesn't contain "stripe". Now matches by provider_key for Stripe.
- QuotaLimit changed to *int64 (null=unlimited, >0=limited)
- Add reset-usage endpoint (POST /admin/settings/web-search-emulation/reset-usage)
- Show quota usage in header always (collapsed and expanded)
- Add reset quota button in expanded provider view
- Quota input: empty=unlimited with ∞ placeholder, must be >0 if set
- Add email verification hint on balance notify card
The buttons were hidden because v-if only checked provider.api_key,
which is always empty for saved providers (backend sanitizes it).
Now also checks api_key_configured. Copy button is disabled when
no actual key is available (only configured placeholder shown).
smtp.SendMail automatically upgrades to STARTTLS when the server
supports it. Our replacement sendMailPlain skipped this, causing
credentials to be sent in plaintext on port 587. Add STARTTLS
negotiation before Auth to restore the original security behavior.
- Skip websearch provider when ProxyID is set but proxy not found (prevent
silent direct connection bypass)
- Fix sortByStableRandomWeight: pair factors with items so sort.Slice swap
keeps weights aligned
- Allow empty platform in account_stats_pricing_rules (wildcard matching),
only force anthropic default for main model_pricing
- Add channel_account_stats_pricing_intervals table and repo layer support
for interval-based pricing in account stats rules
- calculateTokenStatsCost now uses interval pricing when available
- Replace smtp.SendMail/tls.Dial with net.Dialer timeout (10s dial, 20s IO)
to prevent goroutine leak on SMTP hang
- Fix gofmt formatting issues
- Web Search label: black text with red warning hint
- Fix web_search_emulation toggle: explicitly write false for disabled
platforms instead of leaving stale true from cloned features_config
- Extract validatePricingEntries from validateChannelConfig for reuse
- Validate account_stats_pricing_rules[].pricing in both Create and
Update paths (negative prices, bad intervals, missing per_request price)
- Fix websearch provider failover: proxy error from provider-specific proxy
now continues to next provider instead of aborting the entire loop
- Fix SMTP failure locking users out: send email first, then write cache
and increment rate counter
- Fix notify email cache key case sensitivity: normalize to lowercase
- Add OriginalPrice validation to validatePlanPatch and validatePlanRequired
- Add empty scope validation for channel pricing rules (group_ids/account_ids)
- Add platform color to account search dropdown in channel pricing rules
- EditAccountModal width changed from "normal" to "wide" (match CreateAccountModal)
- CreateAccountModal now passes all quota notify props to QuotaLimitCard
- QuotaLimitCard: when global notify disabled, hide title row, input takes full width
- Quota alert email: show remaining quota + threshold (fixed/$, percentage/%) instead of usage trigger point
- Quota alert email now shows account ID and platform
- Balance low email includes a "Top Up Now" button when recharge URL is configured
- New setting: balance_low_notify_recharge_url in admin settings
- QuotaNotifyToggle: add $ or % suffix to threshold input based on type
- QuotaLimitCard: combine reset mode and notify toggle on same row
to reduce vertical height for daily/weekly sections
- Remove redundant ml-4 indentation from QuotaNotifyToggle
Priority was wrong:
- Before: custom rules → LiteLLM (when ApplyPricingToAccountStats) → nil
- After: custom rules → totalCost (when ApplyPricingToAccountStats) → LiteLLM → nil
When ApplyPricingToAccountStats is enabled, use the request's actual
client billing cost (before multiplier) as account_stats_cost, instead
of recalculating from LiteLLM per-token prices which produced incorrect
values for per-request billing mode.
LiteLLM model pricing is now the final fallback (priority 3), used only
when neither custom rules nor ApplyPricingToAccountStats apply.
The field was present in SystemSettings response DTO and service layer
but missing from:
- UpdateSettingsRequest (admin handler) - saves were silently ignored
- GET/PUT response mapping in admin handler
- UpdateSettingsRequest (non-admin dto)
This caused the toggle to always revert to off after saving.
- "默认(跟随渠道)" → "默认", "Default (follow channel)" → "Default"
- Move "follows channel config" info to description text
- Reduce select width from w-32 to w-24 in both Edit and Create modals
QuotaLimitCard now requires quotaNotifyGlobalEnabled prop to control
visibility of QuotaNotifyToggle components. When the global account
quota notification is disabled in admin settings, per-account threshold
toggles are hidden in both Edit and Create account modals.
P0: fix wildcard matching test assertion (config order, not longest prefix)
P0: add TotalRecharged to auth cache snapshot (v5) for percentage threshold
P1: move pricing rules into per-platform sections in ChannelsView
P1: populate account name cache when editing existing channel rules
P1: sanitize email subject headers to prevent SMTP injection
P1: make Redis INCR+EXPIRE idempotent for rate limiting
P1: deep copy FeaturesConfig in Channel.Clone()
P2: clean up stale email="" placeholder comments
P2: replace log.Printf with slog in email_service.go
- resolveAccountStatsCost now uses the final upstream model (after
account-level mapping) to match custom pricing rules, fixing the
issue where requested model (e.g. claude-sonnet-4-5) didn't match
rules configured for upstream model (e.g. claude-opus-4-6)
- Remove tryChannelPricing fallback — only custom rules are applied,
unmatched requests use default formula (total_cost × rate)
- Remove unused billingService and serviceTier parameters
- Update description: "启用后将支持自定义账号统计的模型价格"
- Add "verify" button next to saved unverified emails in
ProfileBalanceNotifyCard (send code → enter code → verify)
- Backend: VerifyAndAddNotifyEmail now marks existing unverified
emails as verified instead of returning "already exists"
- Inline verification UI with countdown timer and resend button
- Fix cached balance causing threshold crossing to never trigger:
read real-time balance from billingCacheService instead of stale
API key auth snapshot
- Remove email="" placeholder concept; all emails are user-managed
- Only send notifications to verified && non-disabled emails
- Frontend: pre-fill user's email in add input when list is empty
- Remove FilterEnabledEmails/IsPrimaryDisabled helpers (no longer needed)
- Change balance_notify_extra_emails and account_quota_notify_emails
from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
fields (Email, Username, BalanceNotifyEnabled, etc.), causing
notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
- Show system default threshold as placeholder in custom threshold input
- Display user's primary email with "Primary" badge
- Support adding multiple pending emails before verification
- Each pending email has independent send/verify/resend flow
- Expose balance_low_notify_threshold in PublicSettings API
- Clean up timers on unmount to prevent leaks
The service layer correctly populated BalanceLowNotifyEnabled and
AccountQuotaNotifyEnabled in PublicSettings, but the handler-to-DTO
mapping was missing. Users could not see the balance notify card because
the public settings API never returned these flags.
Only show the inline eye/copy buttons when provider.api_key has a value.
When only api_key_configured is true (saved key, not loaded), buttons are
hidden since there's nothing to show/copy.
- Basic settings now only shows the global toggle
- Custom pricing rules appear inside each platform tab when toggle is on
- Group selector in rules scoped to the current platform's groups
- Remove unused allFormGroupIds computed
- Add global toggle for account quota notification in admin settings
- Add percentage-based threshold type for per-account quota alerts
- Hide balance notify card on user profile when global toggle is off
- Expose balance_low_notify_enabled and account_quota_notify_enabled in PublicSettings
- Add threshold type (fixed/percentage) to QuotaNotifyToggle with $ / % switcher
- API Key show/copy buttons moved inside input field (inline icons)
- Proxy selector and test button on same row to save vertical space
- Test opens a dialog modal instead of inline display
- Hide all websearch config in channels/accounts when global toggle is off
Balance low notification only supports fixed USD amount threshold.
Percentage threshold is a quota concept, not applicable to balance.
Reverted threshold_type from admin settings, user profile, and all
backend/frontend layers. DB fields (balance_notify_threshold_type,
total_recharged) retained for potential future quota use.
- Fix GetByKeyForAuth not selecting balance notify fields (notifications
never triggered in gateway path)
- Fix provider-level ProxyURL never resolved: inject ProxyRepository into
SettingService, resolve proxy URLs when building Manager
- Fix admin manual balance adjustment not updating total_recharged
- Add threshold_type input validation (reject invalid values)
- Fix user threshold_type inheritance: custom threshold defaults to "fixed"
instead of inheriting global type (prevents $5 being treated as 5%)
- Add try-catch for clipboard.writeText (fails on non-HTTPS)
- Add SetTotalRecharged to user Update for admin balance operations
- Add threshold_type field (fixed/percentage) to system and user settings
- Add total_recharged field to users table, auto-incremented on balance credit
- Percentage mode: effective threshold = total_recharged × percentage / 100
- User-level threshold_type inherits from system default when not set
- Update admin settings UI with radio selector (fixed amount / percentage)
- Migration: 102_add_balance_notify_threshold_type.sql