✨ feat: add subscription billing system - #2808
Conversation
…r purchase flow Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice
… actions Restore the avatar/icon header for the “Model Benefits” section Replace scattered controls with a compact toolbar-style workflow Support multi-select add with a default quota for new items Add row selection with bulk apply-to-selected / apply-to-all quota updates Enable delete-selected to manage benefits faster and reduce mistakes
…d code Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only
Apply consistent code formatting across the entire codebase using gofmt and lint:fix tools. This ensures adherence to Go community standards and improves code readability and maintainability. Changes include: - Run gofmt on all .go files to standardize formatting - Apply lint:fix to automatically resolve linting issues - Fix code style inconsistencies and formatting violations No functional changes were made in this commit.
- Add reset period fields on subscription plans and user items - Apply automatic quota resets during pre-consume based on plan schedule - Expose reset-period configuration in the admin plan editor - Display reset cadence in subscription cards and purchase modal - Validate custom reset seconds on plan create/update
…tency, and production-grade stability Add plan-level quota reset periods and display/reset cadence in admin/UI Enforce natural reset alignment with background reset task and cleanup job Make subscription pre-consume/refund idempotent with request-scoped records and retries Use database time for consistent resets across multi-instance deployments Harden payment callbacks with locking and idempotent order completion Record subscription purchases in topup history and billing logs Optimize subscription queries and add critical composite indexes
Introduce hybrid caches for subscription plans, items, and plan info with explicit invalidation on admin updates. Streamline pre-consume transactions to reduce redundant queries while preserving idempotency and reset logic.
Use a RowsAffected check for the idempotency lookup so missing records no longer surface as "record not found" errors while preserving behavior.
Update the Docker image workflow to run on pushes to the sub branch instead of main.
…ings Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views. Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift. Use global currency display type when creating Creem checkout payloads.
Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update.
Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout.
Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently.
Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary.
Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules.
Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions.
Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect.
Drop the unused originInputs state and redundant updates to keep the Creem settings form state minimal and easier to maintain.
# Conflicts: # main.go # web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx # web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
WalkthroughAdds a subscription & billing system: DB models/migrations, subscription-aware pre/post-consume billing with wallet fallback, Stripe/Creem/ePay payment flows and webhooks, background quota reset task, Relay billing metadata, API controllers, and extensive frontend/admin UI and i18n updates. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant WebUI as Web UI
participant API as Subscription Controller
participant Auth as Auth Middleware
participant Service as Service Layer
participant DB as Database
participant Payment as Payment Gateway
User->>WebUI: Click "purchase" (plan_id)
WebUI->>API: POST /api/subscription/stripe/pay
API->>Auth: Authenticate & authorize
Auth-->>API: User context
API->>Service: Validate plan & limits
Service->>DB: SELECT SubscriptionPlan
DB-->>Service: Plan data
Service->>Payment: Create checkout session (Stripe/Creem/ePay)
Payment-->>Service: Checkout URL / params
Service->>DB: Create SubscriptionOrder (pending)
DB-->>Service: Order created
Service-->>API: pay_link, order_id
API-->>WebUI: Return pay_link
User->>Payment: Complete payment
Payment->>API: Webhook (session.completed)
API->>Service: Lock order, CompleteSubscriptionOrder
Service->>DB: Create/Update UserSubscription, adjust group/cache
DB-->>Service: Success
Service-->>API: 200 OK
API-->>Payment: 200 OK
sequenceDiagram
participant Client
participant Relay
participant Service
participant Model
participant DB
Client->>Relay: Consumption request (tokens)
Relay->>Service: PreConsumeBilling(quota, relayInfo)
Service->>Model: Read BillingPreference / user settings
alt subscription_first
Service->>Model: PreConsumeUserSubscription(userId, quota)
Model->>DB: Lock subscription & deduct
DB-->>Model: Success / insufficient
alt success
Model-->>Service: preConsumed amount
Service-->>Relay: billing=subscription + preConsumed
else insufficient
Service->>Model: PreConsumeTokenQuota(userId, quota)
Model-->>Service: wallet deducted
Service-->>Relay: billing=wallet
end
else wallet_first
Service->>Model: PreConsumeTokenQuota(userId, quota)
Model-->>Service: wallet result
alt wallet sufficient
Service-->>Relay: billing=wallet
else wallet insufficient
Service->>Model: PreConsumeUserSubscription(userId, quota)
Model-->>Service: subscription result
Service-->>Relay: billing=subscription
end
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
web/src/components/table/users/UsersColumnDefs.jsx (1)
211-228:⚠️ Potential issue | 🟡 MinorGuard the subscriptions menu item when the callback is absent.
If
showUserSubscriptionsModalis undefined, clicking the new menu item will throw. Either enforce it as required everywhere or conditionally add the item/divider.♻️ Suggested defensive menu construction
- const moreMenu = [ - { - node: 'item', - name: t('订阅管理'), - onClick: () => showUserSubscriptionsModal(record), - }, - { - node: 'divider', - }, + const subscriptionMenuItems = showUserSubscriptionsModal + ? [ + { + node: 'item', + name: t('订阅管理'), + onClick: () => showUserSubscriptionsModal(record), + }, + { node: 'divider' }, + ] + : []; + + const moreMenu = [ + ...subscriptionMenuItems, { node: 'item', name: t('重置 Passkey'), onClick: () => showResetPasskeyModal(record), },
🤖 Fix all issues with AI agents
In `@controller/subscription_payment_creem.go`:
- Around line 56-58: GetUserById's error is ignored causing possible nil
dereference when accessing user.Email/Username; update the handler in
subscription_payment_creem.go to check the error and/or nil user returned by
model.GetUserById(userId, false) (and consider validating c.GetInt("id") if
necessary), and on failure return an appropriate response (log and send HTTP
error or abort the request) instead of proceeding to use user.Email/Username;
ensure the check references GetUserById and the local variable user so the
function exits early when user is nil or err != nil.
In `@controller/subscription_payment_epay.go`:
- Around line 64-65: The calls to url.Parse for returnUrl and notifyUrl ignore
errors, risking nil/invalid URLs being passed to client.Purchase; update the
code that constructs returnUrl and notifyUrl (calls to url.Parse with
callBackAddress) to check the returned errors, handle parse failures (e.g.,
return an error/HTTP response or log and abort) and only call client.Purchase
when both parses succeed, ensuring you do not dereference nil URLs or pass
malformed values to client.Purchase.
In `@controller/subscription_payment_stripe.go`:
- Around line 53-55: The code calls model.GetUserById(userId, false) and
immediately dereferences user (e.g., user.Id, user.StripeCustomer) without
checking the returned error or nil; modify the handling in the subscription
payment flow to inspect the error/returned user from GetUserById (the variable
user and the call model.GetUserById), and if the lookup fails or user is nil,
return an appropriate error response (HTTP error/abort) and stop processing to
avoid panics or creating invalid orders; ensure downstream code only runs when
user is valid.
- Around line 103-114: The CheckoutSessionParams in genStripeSubscriptionLink
(and related code using priceId/plan.StripePriceId) incorrectly sets Mode to
CheckoutSessionModePayment which isn't compatible with recurring prices; change
the Mode assignment on the CheckoutSessionParams to use
stripe.CheckoutSessionModeSubscription (i.e., set Mode to
string(stripe.CheckoutSessionModeSubscription)) so subscription price IDs work
with Stripe Checkout.
In `@controller/subscription.go`:
- Around line 207-225: The update map inside the model.DB.Transaction call omits
the quota fields so quota_reset_period and quota_reset_custom_seconds validated
from req.Plan are never persisted; update the map named updateMap (used in the
Transaction lambda) to include "quota_reset_period": req.Plan.QuotaResetPeriod
and "quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds (or the exact
field names present on req.Plan) so those values are written to the DB alongside
the other plan fields when the transaction commits.
In `@router/api-router.go`:
- Around line 150-152: Change the EPAY callback routes from GET to POST and
update their handlers to read form-urlencoded POST data: replace
apiRouter.GET("/subscription/epay/notify", ...) and
apiRouter.GET("/subscription/epay/return", ...) with apiRouter.POST(...) and
likewise change userRoute.GET("/epay/notify", ...) in topup.go to
userRoute.POST(...); then modify the handler implementations
SubscriptionEpayNotify, SubscriptionEpayReturn (and the topup epay notify
handler) to parse POST form data (call c.Request.ParseForm() or use
c.PostForm()/c.PostFormMap()) instead of reading from c.Request.URL.Query(), and
use those form values for processing the notification.
In `@service/billing.go`:
- Around line 75-95: The subscription-first branch currently falls back to
tryWallet() on any error from trySubscription(), which can incorrectly charge
wallets for configuration/DB/token errors; change the logic in the
"subscription_first"/default case so that after calling trySubscription() you
inspect the returned error's code (err.GetErrorCode()) and only call tryWallet()
when the code equals the subscription-insufficient code (e.g.,
types.ErrorCodeInsufficientUserQuota), otherwise return the original err; update
the "subscription_first"/default block around trySubscription() to mirror the
wallet_first pattern that checks err.GetErrorCode() before falling back.
In `@service/pre_consume_quota.go`:
- Around line 33-36: refundWithRetry currently swallows errors causing failed
subscription pre-consumes to be silent; change refundWithRetry to return an
error and propagate it to callers (the call sites around needRefundSub where
refundWithRetry(func() error { return
model.RefundSubscriptionPreConsume(relayInfoCopy.RequestId) }) is invoked and
the similar block at lines 55-67). Update the caller(s) to check the returned
error and log it (using the existing logger/processLogger in this file) with
context including relayInfoCopy.RequestId and that the refund retries failed, so
failed refunds are visible in logs.
In `@service/quota.go`:
- Around line 504-517: In PostConsumeQuota the computed delta double-subtracts
usage because callers already pass a quota delta; replace the line computing
delta (currently using int64(quota) - relayInfo.SubscriptionPreConsumed) with a
delta derived directly from the passed quota (e.g., int64(quota)), then call
model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta) and
adjust relayInfo.SubscriptionPostDelta by that same delta; ensure you still
validate SubscriptionId and maintain the existing error handling around
PostConsumeUserSubscriptionDelta.
In `@web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx`:
- Around line 420-430: The UI allows a zero custom duration causing server
rejection; update the Form.InputNumber for field 'custom_seconds' (rendered when
values.duration_unit === 'custom') to set min={1} instead of 0 and add/adjust a
validation rule to require a value >= 1 (e.g., include a rule with type 'number'
and min: 1 or a custom validator that rejects <= 0) so the form prevents
submitting zero-length durations.
In `@web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx`:
- Around line 205-226: renderPaymentConfig always renders the "易支付" tag even
when epay isn't configured; update renderPaymentConfig to conditionally render
the epay tag by checking a reliable flag instead of unconditionally showing it —
either use a new plan-level boolean (e.g. plan.epay_enabled) or read a
system-level payment config passed into the component (e.g.
props.systemPaymentConfig.epayEnabled or presence of EpayId/EpayKey) and only
render the Tag when that flag is true; ensure references to renderPaymentConfig
and record.plan are used so the change is localized and tests/consumers that
call renderPaymentConfig get the config passed through.
In `@web/src/components/topup/index.jsx`:
- Around line 358-372: The function updateBillingPreference currently performs
an optimistic local update via setBillingPreference(pref) before the
API.put('/api/subscription/self/preference') succeeds; change it to capture the
previous value (e.g., const previous = billingPreference) then
setBillingPreference(pref) optimistically, and if the API response is not
successful or an exception occurs call setBillingPreference(previous) to
rollback and show the appropriate error message; ensure both the non-success
branch (res.data?.success false) and the catch block perform the rollback and
still call showError, keeping references to updateBillingPreference,
setBillingPreference, and API.put to locate the change.
In `@web/src/components/topup/modals/SubscriptionPurchaseModal.jsx`:
- Around line 93-96: displayPrice currently decides decimal precision based on
the original price, which causes truncation errors after currency conversion;
change the logic to compute the converted value first (e.g., converted = price *
rate) and then decide precision from that converted number (use
Number.isInteger(converted) or a small epsilon check like Math.abs(converted -
Math.round(converted)) < 1e-8) before calling toFixed, updating the variable
displayPrice to use the converted value and correct integer/decimal rounding;
refer to symbols price, rate, getCurrencyConfig, and displayPrice to locate and
modify the code.
🧹 Nitpick comments (10)
controller/topup_stripe.go (1)
189-204: Subscription order handling logic is sound.The approach of attempting subscription order completion first, then falling back to top-up processing, is clean and well-structured. The locking correctly protects both code paths.
Two minor observations:
The
err != nilcheck on line 200 is redundant—if execution reaches theelse if,erris already non-nil.Log message at line 201 is in English while the rest of the file uses Chinese for log messages (e.g., line 235). Consider aligning for consistency.
♻️ Suggested simplification
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil { return - } else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) { - log.Println("complete subscription order failed:", err.Error(), referenceId) + } else if !errors.Is(err, model.ErrSubscriptionOrderNotFound) { + log.Println("完成订阅订单失败:", err.Error(), referenceId) return }model/db_time.go (1)
10-16: Prefer explicit integer casts in DB timestamp queries.
This avoids driver-dependent conversions (especially SQLite/PostgreSQL) and keepstsconsistently int64.♻️ Suggested adjustment
- err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())").Scan(&ts).Error + err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::BIGINT").Scan(&ts).Error case common.UsingSQLite: - err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error + err = DB.Raw("SELECT CAST(strftime('%s','now') AS INTEGER)").Scan(&ts).Errorcontroller/relay.go (1)
118-123: Propagate generated RequestId into error responses.
When no RequestId is provided, responses will still use the emptyrequestIdcaptured beforeGenRelayInfo. Consider updating it after relay info creation (and optionally set it in context) to keep tracing consistent.🔧 Suggested fix
relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws) if err != nil { newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed) return } + if requestId == "" && relayInfo != nil && relayInfo.RequestId != "" { + requestId = relayInfo.RequestId + c.Set(common.RequestIdKey, requestId) + }controller/subscription_payment_epay.go (3)
118-123: Webhook returns "success" before order completion, which is correct but error logging is missing.The webhook correctly returns "success" after signature verification (line 119) before attempting order completion. However, if
CompleteSubscriptionOrderfails (line 132), the error is silently swallowed. Consider logging the error for debugging and monitoring purposes.📝 Add error logging for failed order completion
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil { - // do not fail webhook response after signature verified + // do not fail webhook response after signature verified, but log for monitoring + common.SysLog(fmt.Sprintf("SubscriptionEpayNotify: failed to complete order %s: %v", verifyInfo.ServiceTradeNo, err)) return }
156-161: Error fromCompleteSubscriptionOrderis silently ignored in return handler.Similar to the notify handler, the return handler ignores the error from
CompleteSubscriptionOrder. While this is acceptable since the notify webhook will also attempt completion, logging would help with debugging.📝 Optional: Add error logging
- _ = model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)) + if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil { + common.SysLog(fmt.Sprintf("SubscriptionEpayReturn: failed to complete order %s: %v", verifyInfo.ServiceTradeNo, err)) + }
148-153: Redirect URL inconsistency: subscription payment redirects to/console/topupinstead of a subscription-specific page.The redirect URLs (lines 148, 153, 160, 163) all point to
/console/topup?pay=...which is the top-up page, not a subscription-specific page. This could confuse users who just purchased a subscription.Consider redirecting to a subscription-related page or adding a query parameter to distinguish subscription purchases.
web/src/components/table/users/UsersTable.jsx (1)
143-156: Dependency array includes callback functions defined within the component.The
useMemodependency array includesshowUserSubscriptionsUserModaland other handler functions. Since these are defined inside the component withoutuseCallback, they will be recreated on every render, causing the memoization to be ineffective.This follows the existing pattern in the file (other handlers like
showPromoteUserModalare also included), so it's consistent, but the columns will be recalculated on every render.Consider wrapping the handlers with
useCallbackif performance becomes a concern. However, since this follows the existing pattern and the component likely doesn't re-render frequently, this is a minor optimization opportunity for the future.web/src/components/table/subscriptions/SubscriptionsTable.jsx (1)
29-31: Consider using standard destructured props pattern.The component receives props as a single positional argument rather than the conventional destructured object pattern. While functional, this deviates from React conventions and reduces clarity at the call site.
♻️ Suggested refactor
-const SubscriptionsTable = (subscriptionsData) => { - const { plans, loading, compactMode, openEdit, setPlanEnabled, t } = - subscriptionsData; +const SubscriptionsTable = ({ plans, loading, compactMode, openEdit, setPlanEnabled, t }) => {web/src/components/table/users/modals/UserSubscriptionsModal.jsx (1)
152-158: Consider addinguser?.idto useEffect dependencies.The effect loads data based on
user?.idbut only depends onvisible. If the modal remains open and the user prop changes (unlikely but possible), the data won't refresh. This is likely acceptable given modal usage patterns, but worth noting.♻️ Suggested fix
useEffect(() => { if (!visible) return; setSelectedPlanId(null); setCurrentPage(1); loadPlans(); loadUserSubscriptions(); - }, [visible]); + }, [visible, user?.id]);web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx (1)
37-65: Consider extracting shared formatting helpers.
formatDurationandformatResetPeriodare duplicated inSubscriptionPlansCard.jsx. Consider extracting these to a shared utility file to improve maintainability.
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end subscription billing system (plans, purchase via multiple gateways, billing preference, admin management) and integrates subscription-based consumption into quota accounting and usage logs.
Changes:
- Introduces subscription plan CRUD + admin/user subscription management endpoints, DB models, and background reset/expiry maintenance.
- Implements subscription-aware pre-consume/post-consume billing flow with request-id idempotency and usage-log enrichment.
- Adds UI for subscription plan management (admin) and subscription purchase/visibility in the top-up page and usage logs.
Reviewed changes
Copilot reviewed 45 out of 45 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/pages/Subscription/index.jsx | Adds admin subscription page entry point. |
| web/src/hooks/usage-logs/useUsageLogsData.jsx | Expands usage log details to show subscription settlement info. |
| web/src/hooks/subscriptions/useSubscriptionsData.jsx | Adds admin hook for listing/editing subscription plans. |
| web/src/hooks/common/useSidebar.js | Enables subscription module in default admin sidebar config. |
| web/src/helpers/render.jsx | Adds lucide icon mapping for subscription sidebar item. |
| web/src/helpers/quota.js | Adds quota<->display amount conversion helpers. |
| web/src/components/topup/modals/TopupHistoryModal.jsx | Labels subscription purchases distinctly in top-up history. |
| web/src/components/topup/modals/SubscriptionPurchaseModal.jsx | Adds modal for purchasing subscription plans via gateways. |
| web/src/components/topup/index.jsx | Integrates subscription plans + billing preference into top-up page. |
| web/src/components/topup/SubscriptionPlansCard.jsx | Adds subscription plans list, purchase actions, and “my subscriptions” display. |
| web/src/components/table/users/modals/UserSubscriptionsModal.jsx | Adds admin UI to view/create/invalidate/delete user subscriptions. |
| web/src/components/table/users/modals/BindSubscriptionModal.jsx | Adds admin UI to bind a subscription plan to a user. |
| web/src/components/table/users/UsersTable.jsx | Wires new “subscription management” modal into users table. |
| web/src/components/table/users/UsersColumnDefs.jsx | Adds “订阅管理” action in user row operations menu. |
| web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx | Displays subscription billing tag/tooltip in usage logs cost column. |
| web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx | Adds admin create/edit subscription plan SideSheet. |
| web/src/components/table/subscriptions/index.jsx | Adds admin subscriptions page layout (actions, description, pagination). |
| web/src/components/table/subscriptions/SubscriptionsTable.jsx | Adds admin subscriptions plans table wrapper. |
| web/src/components/table/subscriptions/SubscriptionsDescription.jsx | Adds header/compact-mode toggle for subscriptions admin page. |
| web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx | Defines admin subscriptions plan columns + enable/disable/edit actions. |
| web/src/components/table/subscriptions/SubscriptionsActions.jsx | Adds “新建套餐” action component. |
| web/src/components/layout/SiderBar.jsx | Adds subscription nav entry and route mapping. |
| web/src/App.jsx | Adds admin route for /console/subscription. |
| service/subscription_reset_task.go | Adds periodic task to expire subscriptions, reset quotas, and cleanup records. |
| service/quota.go | Makes post-consume adjust either wallet quota or subscription usage delta. |
| service/pre_consume_quota.go | Refunds subscription pre-consume records and token quota on request failure. |
| service/log_info_generate.go | Adds billing/subscription metadata into usage log “other” payload. |
| service/billing.go | Adds billing decision logic (subscription vs wallet) based on user preference. |
| router/api-router.go | Adds subscription user/admin APIs and ePay callbacks. |
| relay/common/relay_info.go | Extends RelayInfo with billing/subscription fields + request id. |
| model/user_cache.go | Exposes user group cache update helper for subscription group changes. |
| model/subscription.go | Adds subscription models, plan cache, purchase limits, idempotent pre-consume, reset/expire logic. |
| model/main.go | Registers new subscription tables for migration. |
| model/db_time.go | Adds DB-time helper used for subscription time comparisons. |
| main.go | Starts subscription reset/expiry maintenance task. |
| dto/user_settings.go | Adds billing_preference to user settings DTO. |
| controller/topup_stripe.go | Routes Stripe session completion/expiry to subscription order completion when applicable. |
| controller/topup_creem.go | Routes Creem checkout completion to subscription order completion when applicable. |
| controller/topup.go | Simplifies top-up request payload shape (removes top-up code fields). |
| controller/subscription_payment_stripe.go | Adds Stripe purchase endpoint for subscription plans. |
| controller/subscription_payment_epay.go | Adds ePay purchase + notify/return handlers for subscription plans. |
| controller/subscription_payment_creem.go | Adds Creem purchase endpoint for subscription plans. |
| controller/subscription.go | Adds subscription plans/self/preference APIs + admin plan/user-subscription APIs. |
| controller/relay.go | Switches relay flow to new billing selection logic (subscription-aware). |
| common/str.go | Adds billing preference normalization helper. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@controller/subscription_payment_epay.go`:
- Line 111: The success JSON returned from the handler currently uses
c.JSON(..., gin.H{"message":"success","data":params,"url":uri}) which is
inconsistent with error responses that use common.ApiErrorMsg
({"success":false,"message":...}); update the success response to follow the
same envelope by adding "success": true and matching keys (e.g., {"success":
true, "message": "success", "data": params, "url": uri}) or use an existing
helper equivalent to common.ApiErrorMsg if available so clients receive a
consistent response shape; locate the c.JSON call in the
subscription_payment_epay handler and modify it accordingly.
🧹 Nitpick comments (1)
controller/subscription_payment_epay.go (1)
119-127: Consider extracting duplicate parameter collection logic.The logic for collecting parameters from POST form with query fallback is duplicated between
SubscriptionEpayNotifyandSubscriptionEpayReturn. A helper function would reduce duplication.♻️ Suggested helper extraction
func collectEpayParams(c *gin.Context) map[string]string { params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { r[t] = c.Request.PostForm.Get(t) return r }, map[string]string{}) if len(params) == 0 { params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { r[t] = c.Request.URL.Query().Get(t) return r }, map[string]string{}) } return params }Then use in both handlers:
params := collectEpayParams(c)Also applies to: 164-172
Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
|
@CodeRabbit full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
controller/topup_stripe.go (1)
180-204:⚠️ Potential issue | 🟠 MajorGuard empty
referenceIdbefore locking and processing.Without this,
LockOrder("")is possible andCompleteSubscriptionOrder/Rechageget an empty tradeNo.✅ Suggested fix
referenceId := event.GetObjectValue("client_reference_id") status := event.GetObjectValue("status") if "complete" != status { log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId) return } +if referenceId == "" { + log.Println("未提供支付单号") + return +} // Try complete subscription order first LockOrder(referenceId) defer UnlockOrder(referenceId)
🤖 Fix all issues with AI agents
In `@controller/subscription_payment_creem.go`:
- Around line 97-113: You're using
operation_setting.GetGeneralSetting().QuotaDisplayType to set
CreemProduct.Currency which is a UI display preference and can mislabel
plan.PriceAmount; update the CreemProduct creation to use the actual plan
currency (plan.Currency) or default to "USD" when plan.Currency is empty, and if
you intend to send a different currency than plan.Currency implement an explicit
conversion step for plan.PriceAmount before assigning Price; adjust the code
around the CreemProduct struct construction (referencing CreemProduct,
plan.PriceAmount, plan.Currency and
operation_setting.GetGeneralSetting().QuotaDisplayType) to reflect this change.
In `@controller/subscription.go`:
- Around line 41-63: GetSubscriptionSelf is swallowing DB errors from
model.GetAllUserSubscriptions and model.GetAllActiveUserSubscriptions by
returning empty slices; instead, when those calls return err capture and log the
error (include err and userId) and return an error response to the client (e.g.,
via common.ApiError or appropriate 5xx response) rather than silently returning
empty arrays so the UI isn't misled; update the error branches for both
GetAllUserSubscriptions and GetAllActiveUserSubscriptions to log the error and
short-circuit with an error response referencing GetSubscriptionSelf.
- Around line 160-239: AdminUpdateSubscriptionPlan currently doesn't validate
req.Plan.PriceAmount on updates; add a guard before starting the DB transaction
to reject negative prices. Specifically, in AdminUpdateSubscriptionPlan check if
req.Plan.PriceAmount < 0 and call common.ApiErrorMsg(c, "价格不能为负数") (or similar)
and return; then proceed to build updateMap and
tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap) as
before so negative values never get persisted.
- Around line 110-158: The handler AdminCreateSubscriptionPlan currently doesn't
validate the plan price; add a check that the plan's price_amount field is
non‑negative (e.g., validate req.Plan.PriceAmount >= 0) before persisting: if
negative, return an API error (same pattern as other checks) and abort; place
this validation in AdminCreateSubscriptionPlan just before the DB.Create call so
invalid plans are rejected and do not create crediting orders.
In `@model/db_time.go`:
- Around line 1-22: GetDBTimestamp can panic if the package-level DB is nil; add
a guard at the top of GetDBTimestamp that returns common.GetTimestamp()
immediately when DB is nil (and optionally when DB's underlying connection is
not ready) before calling DB.Raw, so all DB.Raw and .Scan calls are only
executed when DB is non-nil; use the existing DB symbol and keep the fallback to
common.GetTimestamp() unchanged.
In `@model/subscription.go`:
- Around line 1043-1066: RefundSubscriptionPreConsume currently treats a missing
SubscriptionPreConsumeRecord as an error because tx.Where(...).First(&record)
returns gorm.ErrRecordNotFound; update RefundSubscriptionPreConsume to detect
gorm.ErrRecordNotFound after the First call and treat it as a successful no-op
(return nil) so the function is truly idempotent—locate the DB.Transaction block
and the tx.Set(...).Where(...).First(&record).Error check and add logic to
return nil when err == gorm.ErrRecordNotFound before proceeding with status
checks and PostConsumeUserSubscriptionDelta.
In `@web/src/components/table/users/modals/BindSubscriptionModal.jsx`:
- Around line 31-44: The error catch blocks in loadPlans (and the similar block
around lines 71-86) currently call showError with a string, which prevents
showError from handling AxiosError behavior (like 401 redirects); update those
catch handlers to pass the caught error object (e) into showError instead of a
plain string so showError can inspect and handle AxiosError properly while
keeping the existing setLoading(false) in the finally block.
- Around line 54-58: The planOptions mapping in the BindSubscriptionModal
component formats prices inline; update it to use the shared
convertUSDToCurrency utility for consistent formatting (replace the inline
`${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)}` logic with
a call to convertUSDToCurrency(p?.plan?.price_amount, p?.plan?.currency) so
label remains `${p?.plan?.title || ''} (${convertedPrice})`), ensuring you
import convertUSDToCurrency and keep the value set to p?.plan?.id; mirror how
UserSubscriptionsModal builds its labels.
In `@web/src/components/topup/modals/TopupHistoryModal.jsx`:
- Around line 155-158: The isSubscriptionTopup function can throw when
record.trade_no is a non-string because .toLowerCase() assumes a string; change
the coercion to always convert trade_no to a string before lowercasing (e.g.,
use String(record?.trade_no || '') or an equivalent) so trade_no.toLowerCase()
never fails, keeping the existing amount check (Number(record?.amount || 0) ===
0) intact.
In `@web/src/components/topup/SubscriptionPlansCard.jsx`:
- Around line 116-162: payStripe and payCreem open third‑party payment pages
with window.open(url, '_blank') which exposes window.opener; update both calls
(in payStripe and payCreem) to pass windowFeatures 'noopener,noreferrer' as the
third argument (and optionally set openedWindow.opener = null for extra
compatibility) so the new tab cannot access or navigate the opener, then keep
success handling (showSuccess/closeBuy) as-is.
In `@web/src/hooks/usage-logs/useUsageLogsData.jsx`:
- Around line 536-564: The subscription numeric fields from other
(subscription_pre_consumed, subscription_post_delta, subscription_consumed,
subscription_remain, subscription_total) must be coerced to numbers before any
arithmetic; in the block that computes pre, postDelta, finalConsumed and
settlementLines (inside useUsageLogsData.jsx where variables pre, postDelta,
finalConsumed are declared), convert these values using Number(...) or unary +
(e.g., set pre = Number(other?.subscription_pre_consumed ?? 0), postDelta =
Number(other?.subscription_post_delta ?? 0), finalConsumed =
Number(other?.subscription_consumed ?? (pre + postDelta)), and likewise coerce
subscription_remain and subscription_total) so concatenation is prevented and
numeric comparisons like postDelta > 0 work correctly.
In `@web/src/i18n/locales/ru.json`:
- Around line 2622-2625: The translation for the key "订阅套餐管理" uses "тарифы" and
should be changed to use the consistent "план" terminology; update the value for
"订阅套餐管理" from "Управление тарифами подписки" to a phrase using "план" (e.g.,
"Управление планами подписки") so it matches other entries like "套餐": "План" and
"订阅管理": "Управление подписками".
- Around line 2652-2654: Replace the current Russian value for the key "自定义秒数"
(now "Пользовательские секунды") with a more natural phrasing such as
"Произвольное количество секунд" (or "Пользовательское количество секунд") in
ru.json; leave the neighboring keys "有效期单位" and "请输入秒数" unchanged.
- Around line 2665-2667: The Russian translation for the key "暂无订阅记录" is
unnatural; update the value for "暂无订阅记录" to a more natural phrasing such as "Нет
подписок" (or "Записей о подписках нет" if you prefer a fuller sentence) so the
UI reads correctly in Russian.
- Around line 2672-2674: Update the Russian translation for the JSON key
"删除会彻底移除该订阅记录(含权益明细)。是否继续?" to use a more idiomatic term for "权益明细" (e.g.,
"льготы" or "преимущества") instead of "детали прав"; locate the entry with that
exact source string in ru.json and replace the value "Удаление полностью удалит
запись подписки (включая детали прав). Продолжить?" with something like
"Удаление полностью удалит запись подписки (включая льготы). Продолжить?" (or
"включая преимущества") to improve naturalness.
🧹 Nitpick comments (13)
web/src/i18n/locales/ja.json (1)
2612-2612: “原生额度” は日本語として不自然です。Line 2612 は UI 表示として少し硬く感じるので、より自然な表現に寄せるのがおすすめです。
✏️ Suggested tweak
- "原生额度": "生クォータ", + "原生额度": "基本クォータ",web/src/components/topup/modals/TopupHistoryModal.jsx (1)
51-56: Consider normalizingpayment_methodcasing for robustness.While the current backend consistently provides lowercase payment method values and the validation maintains this contract, adding
.toLowerCase()is a defensive best practice. This prevents silent failures if the backend contract ever changes or if payment_method is populated from external sources in the future.♻️ Suggested change
const renderPaymentMethod = (pm) => { - const displayName = PAYMENT_METHOD_MAP[pm]; + const key = (pm || '').toLowerCase(); + const displayName = PAYMENT_METHOD_MAP[key]; return <Text>{displayName ? t(displayName) : pm || '-'}</Text>; };common/str.go (1)
109-116: Avoid trimming twice inNormalizeBillingPreference.♻️ Proposed refactor
func NormalizeBillingPreference(pref string) string { - switch strings.TrimSpace(pref) { + trimmed := strings.TrimSpace(pref) + switch trimmed { case "subscription_first", "wallet_first", "subscription_only", "wallet_only": - return strings.TrimSpace(pref) + return trimmed default: return "subscription_first" } }web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx (1)
214-224: Avoid double-parsingrecord.otherfor billing tags.
renderBillingTagre-parses JSON that the COST renderer already parsed, which can duplicate error logs and work. Consider passingisSubscription(orother) intorenderBillingTag.♻️ Suggested refactor
-function renderBillingTag(record, t) { - const other = getLogOther(record.other); - if (other?.billing_source === 'subscription') { +function renderBillingTag(isSubscription, t) { + if (isSubscription) { return ( <Tag color='green' shape='circle'> {t('订阅抵扣')} </Tag> ); } return null; } ... - const other = getLogOther(record.other); - const isSubscription = other?.billing_source === 'subscription'; + const other = getLogOther(record.other); + const isSubscription = other?.billing_source === 'subscription'; if (isSubscription) { return ( <Tooltip content={`${t('由订阅抵扣')}:${renderQuota(text, 6)}`}> - <span>{renderBillingTag(record, t)}</span> + <span>{renderBillingTag(isSubscription, t)}</span> </Tooltip> ); }Also applies to: 502-515
web/src/components/table/subscriptions/modals/AddEditSubscriptionModal.jsx (1)
144-148: Redundant validation before submit.The manual title validation duplicates the
requiredrule defined at line 283. Semi UI's Form validation should prevent submission when required fields are empty. This check is defensive but unnecessary if form validation is working correctly.web/src/components/table/users/modals/UserSubscriptionsModal.jsx (1)
152-158: Consider addinguser?.idto useEffect dependencies.If the modal remains visible while the
userprop changes (edge case), the subscriptions won't reload. The current pattern works because the modal is typically closed before switching users, but addinguser?.idto the dependency array would be more robust.♻️ Suggested improvement
useEffect(() => { if (!visible) return; setSelectedPlanId(null); setCurrentPage(1); loadPlans(); loadUserSubscriptions(); - }, [visible]); + }, [visible, user?.id]);web/src/App.jsx (1)
47-47: Consider lazy loading for Subscription component.Other pages like
Home,Dashboard,Aboutuselazy()for code splitting. The Subscription component is eagerly imported, which increases the initial bundle size. Consider using lazy loading for consistency and performance.♻️ Suggested change
-import Subscription from './pages/Subscription'; +const Subscription = lazy(() => import('./pages/Subscription'));And wrap the route element with Suspense:
<Route path='/console/subscription' element={ <AdminRoute> - <Subscription /> + <Suspense fallback={<Loading></Loading>} key={location.pathname}> + <Subscription /> + </Suspense> </AdminRoute> } />web/src/hooks/subscriptions/useSubscriptionsData.jsx (1)
121-124: MissingloadPlansin useEffect dependency array.The
useEffectcallsloadPlansbut doesn't include it in the dependency array. While this works becauseloadPlansis stable (no dependencies that would cause recreation), adding an ESLint disable comment or usinguseCallbackwould make the intent clearer.🔧 Optional: Add eslint-disable comment for clarity
// Initialize data on component mount useEffect(() => { loadPlans(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []);controller/subscription_payment_stripe.go (2)
86-98: Order created after Stripe link generation - acceptable for this flow.Unlike the ePay flow, creating the order after generating the Stripe link is acceptable here because:
- If
order.Insert()fails, the user never receives the link and won't complete payment- The Stripe session expires if not used
- The webhook uses
ClientReferenceID(referenceId) which matches the order's TradeNoHowever, consider adding logging when order creation fails after a successful Stripe session creation for debugging purposes.
🔧 Optional: Add logging for debugging
if err := order.Insert(); err != nil { + log.Printf("Failed to create order after Stripe session: referenceId=%s, err=%v", referenceId, err) c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) return }
124-131: Yoda condition style - consider standard Go convention.The condition
"" == customerIduses Yoda-style comparison. While functionally correct, the more idiomatic Go style iscustomerId == "".🔧 Optional: Use idiomatic Go style
- if "" == customerId { - if "" != email { + if customerId == "" { + if email != "" { params.CustomerEmail = stripe.String(email) }service/subscription_reset_task.go (1)
47-69: Early return on expiration error may leave work undone.When
ExpireDueSubscriptionsfails, the function returns early without attemptingResetDueSubscriptionsor cleanup. While this prevents cascading failures, consider whether partial progress is acceptable.The current approach is reasonable for a periodic task that will retry on the next tick, but consider logging more context about which subscriptions may have been affected.
🔧 Optional: Continue to reset phase even if expire fails
for { n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize) if err != nil { logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err)) - return + break // Continue to reset phase instead of returning } if n == 0 { break }controller/subscription_payment_epay.go (1)
114-128: Consider adding logging for debugging webhook issues.The notify handler silently fails on various error conditions. Adding logging would help diagnose production issues with payment callbacks.
🔧 Optional: Add logging for debugging
func SubscriptionEpayNotify(c *gin.Context) { if err := c.Request.ParseForm(); err != nil { + log.Printf("SubscriptionEpayNotify: ParseForm failed: %v", err) _, _ = c.Writer.Write([]byte("fail")) return } // ... rest of params handling ... client := GetEpayClient() if client == nil { + log.Println("SubscriptionEpayNotify: ePay client not configured") _, _ = c.Writer.Write([]byte("fail")) return } verifyInfo, err := client.Verify(params) if err != nil || !verifyInfo.VerifyStatus { + log.Printf("SubscriptionEpayNotify: verify failed: err=%v, status=%v", err, verifyInfo) _, _ = c.Writer.Write([]byte("fail")) return }controller/subscription.go (1)
241-262: Consider updatingupdated_atwhen toggling enabled.
Keeps metadata consistent for admin auditing.🔧 Suggested tweak
- if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil { + if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id). + Updates(map[string]interface{}{ + "enabled": *req.Enabled, + "updated_at": common.GetTimestamp(), + }).Error; err != nil { common.ApiError(c, err) return }
| // Reuse Creem checkout generator by building a lightweight product reference. | ||
| currency := "USD" | ||
| switch operation_setting.GetGeneralSetting().QuotaDisplayType { | ||
| case operation_setting.QuotaDisplayTypeCNY: | ||
| currency = "CNY" | ||
| case operation_setting.QuotaDisplayTypeUSD: | ||
| currency = "USD" | ||
| default: | ||
| currency = "USD" | ||
| } | ||
| product := &CreemProduct{ | ||
| ProductId: plan.CreemProductId, | ||
| Name: plan.Title, | ||
| Price: plan.PriceAmount, | ||
| Currency: currency, | ||
| Quota: 0, | ||
| } |
There was a problem hiding this comment.
Avoid deriving Creem charge currency from display settings.
QuotaDisplayType is a UI preference; using it to set Currency without converting plan.PriceAmount can mischarge (e.g., USD amount labeled as CNY). Use plan.Currency (or always USD) or convert the amount before sending to Creem.
💡 Suggested fix
- currency := "USD"
- switch operation_setting.GetGeneralSetting().QuotaDisplayType {
- case operation_setting.QuotaDisplayTypeCNY:
- currency = "CNY"
- case operation_setting.QuotaDisplayTypeUSD:
- currency = "USD"
- default:
- currency = "USD"
- }
+ currency := plan.Currency
+ if currency == "" {
+ currency = "USD"
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Reuse Creem checkout generator by building a lightweight product reference. | |
| currency := "USD" | |
| switch operation_setting.GetGeneralSetting().QuotaDisplayType { | |
| case operation_setting.QuotaDisplayTypeCNY: | |
| currency = "CNY" | |
| case operation_setting.QuotaDisplayTypeUSD: | |
| currency = "USD" | |
| default: | |
| currency = "USD" | |
| } | |
| product := &CreemProduct{ | |
| ProductId: plan.CreemProductId, | |
| Name: plan.Title, | |
| Price: plan.PriceAmount, | |
| Currency: currency, | |
| Quota: 0, | |
| } | |
| // Reuse Creem checkout generator by building a lightweight product reference. | |
| currency := plan.Currency | |
| if currency == "" { | |
| currency = "USD" | |
| } | |
| product := &CreemProduct{ | |
| ProductId: plan.CreemProductId, | |
| Name: plan.Title, | |
| Price: plan.PriceAmount, | |
| Currency: currency, | |
| Quota: 0, | |
| } |
🤖 Prompt for AI Agents
In `@controller/subscription_payment_creem.go` around lines 97 - 113, You're using
operation_setting.GetGeneralSetting().QuotaDisplayType to set
CreemProduct.Currency which is a UI display preference and can mislabel
plan.PriceAmount; update the CreemProduct creation to use the actual plan
currency (plan.Currency) or default to "USD" when plan.Currency is empty, and if
you intend to send a different currency than plan.Currency implement an explicit
conversion step for plan.PriceAmount before assigning Price; adjust the code
around the CreemProduct struct construction (referencing CreemProduct,
plan.PriceAmount, plan.Currency and
operation_setting.GetGeneralSetting().QuotaDisplayType) to reflect this change.
| func GetSubscriptionSelf(c *gin.Context) { | ||
| userId := c.GetInt("id") | ||
| settingMap, _ := model.GetUserSetting(userId, false) | ||
| pref := common.NormalizeBillingPreference(settingMap.BillingPreference) | ||
|
|
||
| // Get all subscriptions (including expired) | ||
| allSubscriptions, err := model.GetAllUserSubscriptions(userId) | ||
| if err != nil { | ||
| allSubscriptions = []model.SubscriptionSummary{} | ||
| } | ||
|
|
||
| // Get active subscriptions for backward compatibility | ||
| activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId) | ||
| if err != nil { | ||
| activeSubscriptions = []model.SubscriptionSummary{} | ||
| } | ||
|
|
||
| common.ApiSuccess(c, gin.H{ | ||
| "billing_preference": pref, | ||
| "subscriptions": activeSubscriptions, // all active subscriptions | ||
| "all_subscriptions": allSubscriptions, // all subscriptions including expired | ||
| }) | ||
| } |
There was a problem hiding this comment.
Don’t swallow DB errors when loading subscriptions.
Returning empty arrays on errors makes outages look like “no subscriptions.” Prefer surfacing the error (or at least logging) to avoid misleading UI.
💡 Suggested fix
- allSubscriptions, err := model.GetAllUserSubscriptions(userId)
- if err != nil {
- allSubscriptions = []model.SubscriptionSummary{}
- }
+ allSubscriptions, err := model.GetAllUserSubscriptions(userId)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
- activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
- if err != nil {
- activeSubscriptions = []model.SubscriptionSummary{}
- }
+ activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }🤖 Prompt for AI Agents
In `@controller/subscription.go` around lines 41 - 63, GetSubscriptionSelf is
swallowing DB errors from model.GetAllUserSubscriptions and
model.GetAllActiveUserSubscriptions by returning empty slices; instead, when
those calls return err capture and log the error (include err and userId) and
return an error response to the client (e.g., via common.ApiError or appropriate
5xx response) rather than silently returning empty arrays so the UI isn't
misled; update the error branches for both GetAllUserSubscriptions and
GetAllActiveUserSubscriptions to log the error and short-circuit with an error
response referencing GetSubscriptionSelf.
| func AdminCreateSubscriptionPlan(c *gin.Context) { | ||
| var req AdminUpsertSubscriptionPlanRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| req.Plan.Id = 0 | ||
| if strings.TrimSpace(req.Plan.Title) == "" { | ||
| common.ApiErrorMsg(c, "套餐标题不能为空") | ||
| return | ||
| } | ||
| if req.Plan.Currency == "" { | ||
| req.Plan.Currency = "USD" | ||
| } | ||
| req.Plan.Currency = "USD" | ||
| if req.Plan.DurationUnit == "" { | ||
| req.Plan.DurationUnit = model.SubscriptionDurationMonth | ||
| } | ||
| if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { | ||
| req.Plan.DurationValue = 1 | ||
| } | ||
| if req.Plan.MaxPurchasePerUser < 0 { | ||
| common.ApiErrorMsg(c, "购买上限不能为负数") | ||
| return | ||
| } | ||
| if req.Plan.TotalAmount < 0 { | ||
| common.ApiErrorMsg(c, "总额度不能为负数") | ||
| return | ||
| } | ||
| req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) | ||
| if req.Plan.UpgradeGroup != "" { | ||
| if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { | ||
| common.ApiErrorMsg(c, "升级分组不存在") | ||
| return | ||
| } | ||
| } | ||
| req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) | ||
| if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { | ||
| common.ApiErrorMsg(c, "自定义重置周期需大于0秒") | ||
| return | ||
| } | ||
| err := model.DB.Create(&req.Plan).Error | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| model.InvalidateSubscriptionPlanCache(req.Plan.Id) | ||
| common.ApiSuccess(c, req.Plan) | ||
| } |
There was a problem hiding this comment.
Validate that price_amount is non‑negative.
Negative prices can create invalid or crediting orders.
🧾 Suggested fix
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
+ if req.Plan.PriceAmount < 0 {
+ common.ApiErrorMsg(c, "金额不能为负数")
+ return
+ }
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}🤖 Prompt for AI Agents
In `@controller/subscription.go` around lines 110 - 158, The handler
AdminCreateSubscriptionPlan currently doesn't validate the plan price; add a
check that the plan's price_amount field is non‑negative (e.g., validate
req.Plan.PriceAmount >= 0) before persisting: if negative, return an API error
(same pattern as other checks) and abort; place this validation in
AdminCreateSubscriptionPlan just before the DB.Create call so invalid plans are
rejected and do not create crediting orders.
| func AdminUpdateSubscriptionPlan(c *gin.Context) { | ||
| id, _ := strconv.Atoi(c.Param("id")) | ||
| if id <= 0 { | ||
| common.ApiErrorMsg(c, "无效的ID") | ||
| return | ||
| } | ||
| var req AdminUpsertSubscriptionPlanRequest | ||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| common.ApiErrorMsg(c, "参数错误") | ||
| return | ||
| } | ||
| if strings.TrimSpace(req.Plan.Title) == "" { | ||
| common.ApiErrorMsg(c, "套餐标题不能为空") | ||
| return | ||
| } | ||
| req.Plan.Id = id | ||
| if req.Plan.Currency == "" { | ||
| req.Plan.Currency = "USD" | ||
| } | ||
| req.Plan.Currency = "USD" | ||
| if req.Plan.DurationUnit == "" { | ||
| req.Plan.DurationUnit = model.SubscriptionDurationMonth | ||
| } | ||
| if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom { | ||
| req.Plan.DurationValue = 1 | ||
| } | ||
| if req.Plan.MaxPurchasePerUser < 0 { | ||
| common.ApiErrorMsg(c, "购买上限不能为负数") | ||
| return | ||
| } | ||
| if req.Plan.TotalAmount < 0 { | ||
| common.ApiErrorMsg(c, "总额度不能为负数") | ||
| return | ||
| } | ||
| req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup) | ||
| if req.Plan.UpgradeGroup != "" { | ||
| if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok { | ||
| common.ApiErrorMsg(c, "升级分组不存在") | ||
| return | ||
| } | ||
| } | ||
| req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod) | ||
| if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 { | ||
| common.ApiErrorMsg(c, "自定义重置周期需大于0秒") | ||
| return | ||
| } | ||
|
|
||
| err := model.DB.Transaction(func(tx *gorm.DB) error { | ||
| // update plan (allow zero values updates with map) | ||
| updateMap := map[string]interface{}{ | ||
| "title": req.Plan.Title, | ||
| "subtitle": req.Plan.Subtitle, | ||
| "price_amount": req.Plan.PriceAmount, | ||
| "currency": req.Plan.Currency, | ||
| "duration_unit": req.Plan.DurationUnit, | ||
| "duration_value": req.Plan.DurationValue, | ||
| "custom_seconds": req.Plan.CustomSeconds, | ||
| "enabled": req.Plan.Enabled, | ||
| "sort_order": req.Plan.SortOrder, | ||
| "stripe_price_id": req.Plan.StripePriceId, | ||
| "creem_product_id": req.Plan.CreemProductId, | ||
| "max_purchase_per_user": req.Plan.MaxPurchasePerUser, | ||
| "total_amount": req.Plan.TotalAmount, | ||
| "upgrade_group": req.Plan.UpgradeGroup, | ||
| "quota_reset_period": req.Plan.QuotaResetPeriod, | ||
| "quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds, | ||
| "updated_at": common.GetTimestamp(), | ||
| } | ||
| if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil { | ||
| return err | ||
| } | ||
| return nil | ||
| }) | ||
| if err != nil { | ||
| common.ApiError(c, err) | ||
| return | ||
| } | ||
| model.InvalidateSubscriptionPlanCache(id) | ||
| common.ApiSuccess(c, nil) | ||
| } |
There was a problem hiding this comment.
Validate that price_amount is non‑negative on update as well.
Keeps stored pricing consistent and safe.
🧾 Suggested fix
if req.Plan.MaxPurchasePerUser < 0 {
common.ApiErrorMsg(c, "购买上限不能为负数")
return
}
+ if req.Plan.PriceAmount < 0 {
+ common.ApiErrorMsg(c, "金额不能为负数")
+ return
+ }
if req.Plan.TotalAmount < 0 {
common.ApiErrorMsg(c, "总额度不能为负数")
return
}🤖 Prompt for AI Agents
In `@controller/subscription.go` around lines 160 - 239,
AdminUpdateSubscriptionPlan currently doesn't validate req.Plan.PriceAmount on
updates; add a guard before starting the DB transaction to reject negative
prices. Specifically, in AdminUpdateSubscriptionPlan check if
req.Plan.PriceAmount < 0 and call common.ApiErrorMsg(c, "价格不能为负数") (or similar)
and return; then proceed to build updateMap and
tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap) as
before so negative values never get persisted.
| package model | ||
|
|
||
| import "github.com/QuantumNous/new-api/common" | ||
|
|
||
| // GetDBTimestamp returns a UNIX timestamp from database time. | ||
| // Falls back to application time on error. | ||
| func GetDBTimestamp() int64 { | ||
| var ts int64 | ||
| var err error | ||
| switch { | ||
| case common.UsingPostgreSQL: | ||
| err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error | ||
| case common.UsingSQLite: | ||
| err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error | ||
| default: | ||
| err = DB.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error | ||
| } | ||
| if err != nil || ts <= 0 { | ||
| return common.GetTimestamp() | ||
| } | ||
| return ts | ||
| } |
There was a problem hiding this comment.
Guard against nil DB to prevent panics.
If GetDBTimestamp() can be called before DB initialization (tests, early startup), DB.Raw will panic. Add a nil check and fall back to common.GetTimestamp().
🛡️ Suggested fix
func GetDBTimestamp() int64 {
+ if DB == nil {
+ return common.GetTimestamp()
+ }
var ts int64
var err error
switch {🤖 Prompt for AI Agents
In `@model/db_time.go` around lines 1 - 22, GetDBTimestamp can panic if the
package-level DB is nil; add a guard at the top of GetDBTimestamp that returns
common.GetTimestamp() immediately when DB is nil (and optionally when DB's
underlying connection is not ready) before calling DB.Raw, so all DB.Raw and
.Scan calls are only executed when DB is non-nil; use the existing DB symbol and
keep the fallback to common.GetTimestamp() unchanged.
| if (other?.billing_source === 'subscription') { | ||
| const planId = other?.subscription_plan_id; | ||
| const planTitle = other?.subscription_plan_title || ''; | ||
| const subscriptionId = other?.subscription_id; | ||
| const unit = t('额度'); | ||
| const pre = other?.subscription_pre_consumed ?? 0; | ||
| const postDelta = other?.subscription_post_delta ?? 0; | ||
| const finalConsumed = other?.subscription_consumed ?? pre + postDelta; | ||
| const remain = other?.subscription_remain; | ||
| const total = other?.subscription_total; | ||
| // Use multiple Description items to avoid an overlong single line. | ||
| if (planId) { | ||
| expandDataLocal.push({ | ||
| key: t('订阅套餐'), | ||
| value: `#${planId} ${planTitle}`.trim(), | ||
| }); | ||
| } | ||
| if (subscriptionId) { | ||
| expandDataLocal.push({ | ||
| key: t('订阅实例'), | ||
| value: `#${subscriptionId}`, | ||
| }); | ||
| } | ||
| const settlementLines = [ | ||
| `${t('预扣')}:${pre} ${unit}`, | ||
| `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`, | ||
| `${t('最终抵扣')}:${finalConsumed} ${unit}`, | ||
| ] | ||
| .filter(Boolean) |
There was a problem hiding this comment.
Coerce subscription numeric fields to numbers before arithmetic.
If the API returns these fields as strings, pre + postDelta can concatenate (e.g., "1" + "2" → "12"), which breaks finalConsumed and the settlement sign logic. Coercing upfront avoids this.
🔧 Proposed fix
- const pre = other?.subscription_pre_consumed ?? 0;
- const postDelta = other?.subscription_post_delta ?? 0;
- const finalConsumed = other?.subscription_consumed ?? pre + postDelta;
+ const pre = Number(other?.subscription_pre_consumed ?? 0);
+ const postDelta = Number(other?.subscription_post_delta ?? 0);
+ const finalConsumed = Number(
+ other?.subscription_consumed ?? pre + postDelta,
+ );🤖 Prompt for AI Agents
In `@web/src/hooks/usage-logs/useUsageLogsData.jsx` around lines 536 - 564, The
subscription numeric fields from other (subscription_pre_consumed,
subscription_post_delta, subscription_consumed, subscription_remain,
subscription_total) must be coerced to numbers before any arithmetic; in the
block that computes pre, postDelta, finalConsumed and settlementLines (inside
useUsageLogsData.jsx where variables pre, postDelta, finalConsumed are
declared), convert these values using Number(...) or unary + (e.g., set pre =
Number(other?.subscription_pre_consumed ?? 0), postDelta =
Number(other?.subscription_post_delta ?? 0), finalConsumed =
Number(other?.subscription_consumed ?? (pre + postDelta)), and likewise coerce
subscription_remain and subscription_total) so concatenation is prevented and
numeric comparisons like postDelta > 0 work correctly.
| "订阅管理": "Управление подписками", | ||
| "订阅套餐管理": "Управление тарифами подписки", | ||
| "新建套餐": "Создать план", | ||
| "套餐": "План", |
There was a problem hiding this comment.
Keep “план” terminology consistent for subscriptions.
Most entries use “план”, but this line switches to “тарифы”, which is jarring in the same UI.
📝 Suggested wording
-"订阅套餐管理": "Управление тарифами подписки",
+"订阅套餐管理": "Управление планами подписки",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "订阅管理": "Управление подписками", | |
| "订阅套餐管理": "Управление тарифами подписки", | |
| "新建套餐": "Создать план", | |
| "套餐": "План", | |
| "订阅管理": "Управление подписками", | |
| "订阅套餐管理": "Управление планами подписки", | |
| "新建套餐": "Создать план", | |
| "套餐": "План", |
🤖 Prompt for AI Agents
In `@web/src/i18n/locales/ru.json` around lines 2622 - 2625, The translation for
the key "订阅套餐管理" uses "тарифы" and should be changed to use the consistent
"план" terminology; update the value for "订阅套餐管理" from "Управление тарифами
подписки" to a phrase using "план" (e.g., "Управление планами подписки") so it
matches other entries like "套餐": "План" and "订阅管理": "Управление подписками".
| "有效期单位": "Единица срока", | ||
| "自定义秒数": "Пользовательские секунды", | ||
| "请输入秒数": "Введите количество секунд", |
There was a problem hiding this comment.
Polish “自定义秒数” phrasing.
“Пользовательские секунды” sounds unnatural; use a clearer term for a custom number of seconds.
📝 Suggested wording
-"自定义秒数": "Пользовательские секунды",
+"自定义秒数": "Пользовательское количество секунд",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "有效期单位": "Единица срока", | |
| "自定义秒数": "Пользовательские секунды", | |
| "请输入秒数": "Введите количество секунд", | |
| "有效期单位": "Единица срока", | |
| "自定义秒数": "Пользовательское количество секунд", | |
| "请输入秒数": "Введите количество секунд", |
🤖 Prompt for AI Agents
In `@web/src/i18n/locales/ru.json` around lines 2652 - 2654, Replace the current
Russian value for the key "自定义秒数" (now "Пользовательские секунды") with a more
natural phrasing such as "Произвольное количество секунд" (or "Пользовательское
количество секунд") in ru.json; leave the neighboring keys "有效期单位" and "请输入秒数"
unchanged.
| "新增订阅": "Добавить подписку", | ||
| "暂无订阅记录": "Нет записей подписок", | ||
| "来源": "Источник", |
There was a problem hiding this comment.
Naturalize “暂无订阅记录”.
“Нет записей подписок” reads awkwardly in Russian.
📝 Suggested wording
-"暂无订阅记录": "Нет записей подписок",
+"暂无订阅记录": "Нет записей о подписках",🤖 Prompt for AI Agents
In `@web/src/i18n/locales/ru.json` around lines 2665 - 2667, The Russian
translation for the key "暂无订阅记录" is unnatural; update the value for "暂无订阅记录" to
a more natural phrasing such as "Нет подписок" (or "Записей о подписках нет" if
you prefer a fuller sentence) so the UI reads correctly in Russian.
| "作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?", | ||
| "删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?", | ||
| "绑定订阅套餐": "Привязать план подписки", |
There was a problem hiding this comment.
Clarify “权益明细” translation.
“Детали прав” is not idiomatic here; “льготы/преимущества” reads better for entitlements.
📝 Suggested wording
-"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?",
+"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали льгот). Продолжить?",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?", | |
| "删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?", | |
| "绑定订阅套餐": "Привязать план подписки", | |
| "作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?", | |
| "删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали льгот). Продолжить?", | |
| "绑定订阅套餐": "Привязать план подписки", |
🤖 Prompt for AI Agents
In `@web/src/i18n/locales/ru.json` around lines 2672 - 2674, Update the Russian
translation for the JSON key "删除会彻底移除该订阅记录(含权益明细)。是否继续?" to use a more idiomatic
term for "权益明细" (e.g., "льготы" or "преимущества") instead of "детали прав";
locate the entry with that exact source string in ru.json and replace the value
"Удаление полностью удалит запись подписки (включая детали прав). Продолжить?"
with something like "Удаление полностью удалит запись подписки (включая льготы).
Продолжить?" (or "включая преимущества") to improve naturalness.
* ci: create docker automation * ✨ feat: add subscription billing system with admin management and user purchase flow Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests * ✨ feat(admin): add user subscription management and refine UI/pagination Add admin APIs to list/create/invalidate/delete user subscriptions Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete Wire new admin routes for user subscription operations Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table Use CardTable with responsive layout and working client-side pagination inside the SideSheet Improve subscription purchase modal empty-gateway state with a Banner notice * ✨ feat(admin): streamline subscription plan benefits editor with bulk actions Restore the avatar/icon header for the “Model Benefits” section Replace scattered controls with a compact toolbar-style workflow Support multi-select add with a default quota for new items Add row selection with bulk apply-to-selected / apply-to-all quota updates Enable delete-selected to manage benefits faster and reduce mistakes * ✨ fix(subscription): finalize payments, log billing, and clean up dead code Complete subscription orders by creating a matching top-up record and writing billing logs Add Epay return handler to verify and finalize browser callbacks Require Stripe/Creem webhook configuration before starting subscription payments Show subscription purchases in topup history with clearer labels/methods Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields Simplify subscription self API payload to active/all lists only * 🎨 style: format all code with gofmt and lint:fix Apply consistent code formatting across the entire codebase using gofmt and lint:fix tools. This ensures adherence to Go community standards and improves code readability and maintainability. Changes include: - Run gofmt on all .go files to standardize formatting - Apply lint:fix to automatically resolve linting issues - Fix code style inconsistencies and formatting violations No functional changes were made in this commit. * ✨ feat(subscription): add quota reset periods and admin configuration - Add reset period fields on subscription plans and user items - Apply automatic quota resets during pre-consume based on plan schedule - Expose reset-period configuration in the admin plan editor - Display reset cadence in subscription cards and purchase modal - Validate custom reset seconds on plan create/update * ✨ feat(subscription): harden subscription billing with resets, idempotency, and production-grade stability Add plan-level quota reset periods and display/reset cadence in admin/UI Enforce natural reset alignment with background reset task and cleanup job Make subscription pre-consume/refund idempotent with request-scoped records and retries Use database time for consistent resets across multi-instance deployments Harden payment callbacks with locking and idempotent order completion Record subscription purchases in topup history and billing logs Optimize subscription queries and add critical composite indexes * ✨ feat(subscription): cache plan lookups and stabilize pre-consume Introduce hybrid caches for subscription plans, items, and plan info with explicit invalidation on admin updates. Streamline pre-consume transactions to reduce redundant queries while preserving idempotency and reset logic. * 🐛 fix(subscription): avoid pre-consume lookup noise Use a RowsAffected check for the idempotency lookup so missing records no longer surface as "record not found" errors while preserving behavior. * 🔧 ci: Change workflow trigger to sub branch Update the Docker image workflow to run on pushes to the sub branch instead of main. * 💸 chore: Align subscription pricing display with global currency settings Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views. Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift. Use global currency display type when creating Creem checkout payloads. * 🔧 chore: Unify subscription plan status toggle with PATCH endpoint Replace separate enable/disable flows with a single PATCH API that updates the enabled flag. Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent. Introduce a minimal admin controller handler and route for the status update. * ✨ feat: Add subscription limits and UI tags consistency Add per-plan purchase limits with backend enforcement and UI disable states. Expose limit configuration in admin plan editor and show limits in plan tables/cards. Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout. * 🎨 style: tag color to white * 🚀 refactor: Simplify subscription quota to total amount model Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently. * 🚀 chore: Remove duplicate subscription usage percentage display Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary. * ✨ feat: Add subscription upgrade group with auto downgrade * ✨ feat: Update subscription purchase modal display Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules. * ✨ feat: Extract quota conversion helpers to shared utils Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions. * ✨ chore: Add upgrade group guidance in subscription editor Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect. * 🔧 chore: remove unused Creem settings state Drop the unused originInputs state and redundant updates to keep the Creem settings form state minimal and easier to maintain. * 🚀 chore: Remove useless action * ✨ Add full i18n coverage for subscription-related UI across locales * ✨ feat: harden subscription billing and improve UI consistency Improve subscription payment safety and data integrity by handling user/URL lookup failures, fixing Stripe subscription mode, persisting quota reset fields, and correcting subscription delta accounting and DB timestamp casting. Refine the UI with stricter custom duration validation, accurate currency rounding, conditional Epay labeling, rollback on preference update failure, and shared subscription formatting helpers plus clearer component naming. * 🔧 fix: make epay webhook and return flow subscription-aware Ensure Epay webhook acknowledges success only after order completion, returning fail on processing errors to allow retries. Redirect subscription payment returns to the subscription page instead of top-up for correct user flow. * 🚦 fix: guard epay return success on order completion Redirect subscription return flow to failure when order completion fails, preventing false success states after payment verification. * 🔧 fix: normalize epay error handling and webhook retries Standardize SubscriptionRequestEpay error responses via ApiErrorMsg for a consistent schema. Return "fail" on non-success trade statuses in the epay webhook to preserve retry behavior. * 🧾 fix: persist epay orders before purchase Create the subscription order before initiating epay payment and expire it if the provider call fails, preventing orphaned transactions and improving reconciliation. * 🔧 fix: harden epay callbacks and billing fallbacks Use POST and form parsing for epay notify/return routes, persist epay orders before provider calls with expiry on failure, and ensure notify handlers retry correctly. Restrict subscription-first fallback to insufficient-subscription errors and log refund failures after retries to avoid silent quota drift. * 🔧 fix: harden billing flow and sidebar settings Add missing strings import for subscription fallback checks, log failed subscription refunds after retries, and extend sidebar module settings with a subscription management toggle plus translations. * 🛡️ fix: fail fast on epay form parse errors Handle ParseForm errors in epay notify/return handlers by returning fail or redirecting to failure, avoiding unsafe fallback to query parameters. * ✨ fix: refine Japanese subscription status labels Adjust Japanese UI wording for active-count labels to read more naturally and consistently. * ✅ fix: standardize epay success response schema Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
Summary by CodeRabbit
New Features
Refactor
Style/UX