Skip to content

✨ feat: add subscription billing system - #2808

Merged
Calcium-Ion merged 48 commits into
mainfrom
feature/subscription
Feb 3, 2026
Merged

✨ feat: add subscription billing system#2808
Calcium-Ion merged 48 commits into
mainfrom
feature/subscription

Conversation

@t0ng7u

@t0ng7u t0ng7u commented Feb 2, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Full subscription system: browse/purchase plans, manage user subscriptions, admin plan CRUD, bind/invalidate/delete subscriptions.
    • Multiple payment gateways: Stripe, Creem, ePay with checkout flows, webhooks and return handlers.
    • User billing preference, per-user purchase limits, subscription-aware billing in top-up and usage UIs.
  • Refactor

    • Unified billing flow (subscription vs wallet) with pre/post-consume handling, relay tracing, and background quota reset/cleanup task.
  • Style/UX

    • Admin subscription pages, modals, plan cards, billing tags in logs, and extensive i18n additions.

t0ng7u added 24 commits January 30, 2026 01:58
…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
@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds 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

Cohort / File(s) Summary
Models & migrations
model/subscription.go, model/db_time.go, model/main.go, model/user_cache.go
Add subscription domain models (plans, orders, user subscriptions, pre-consume records, plan info), DB timestamp helper, migration registration, and exported UpdateUserGroupCache wrapper.
Service: billing, quota & tasks
service/billing.go, service/pre_consume_quota.go, service/quota.go, service/subscription_reset_task.go
Introduce PreConsumeBilling (wallet vs subscription), subscription-aware PostConsume delta handling, batched refund retry logic, and background subscription quota reset/expire worker.
Relay & logging
relay/common/relay_info.go, service/log_info_generate.go, controller/relay.go
Extend RelayInfo with billing/subscription/tracing fields (RequestId, subscription metadata), initialize RequestId, append billing info to logs, and switch pre-consume call to PreConsumeBilling.
Controllers: subscriptions & payments
controller/subscription.go, controller/subscription_payment_stripe.go, controller/subscription_payment_creem.go, controller/subscription_payment_epay.go, controller/topup_creem.go, controller/topup_stripe.go, controller/topup.go
Add subscription user/admin endpoints and DTOs; add Stripe/Creem/ePay payment handlers, webhooks/returns, per-order locking, subscription-priority webhook processing, and minor top-up request field removals.
DTOs & small helpers
dto/user_settings.go, common/str.go
Add BillingPreference to UserSetting and NormalizeBillingPreference(pref string) to trim/validate/normalize billing preference values.
Routing & startup
router/api-router.go, main.go
Register subscription routes (user/admin/public callbacks) and start subscription quota reset background task at startup.
Frontend — admin UI
web/src/components/table/subscriptions/..., web/src/hooks/subscriptions/useSubscriptionsData.jsx, web/src/pages/Subscription/index.jsx, web/src/App.jsx, web/src/hooks/common/useSidebar.js
Implement admin subscription UI: list, create/edit modal, table columns/actions, data hook, route and sidebar enablement.
Frontend — user/topup UI & helpers
web/src/components/topup/*, web/src/helpers/quota.js, web/src/helpers/subscriptionFormat.js
Top-up page shows subscription plans, billing preference control, purchase modal supporting Stripe/Creem/ePay, quota conversion helpers, and subscription-aware history rendering.
User management UI
web/src/components/table/users/..., web/src/components/table/users/modals/*
Add BindSubscriptionModal and UserSubscriptionsModal; integrate subscription actions into users table and operations menu.
Usage logs, icons & UI tweaks
web/src/components/table/usage-logs/..., web/src/helpers/render.jsx
Add subscription billing tag/details in usage logs and a subscription icon; many subscription UI components/column defs added.
Top-up webhook/payment handlers
controller/topup_creem.go, controller/topup_stripe.go
Prioritize subscription-order handling in webhook flows, add order locking and early-return, retain fallback generic recharge logic.
Frontend i18n
web/src/i18n/locales/*
Add extensive subscription/payment localization across locales and remove/reorder a few small keys.

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

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • seefs001
  • creamlike1024

Poem

🐰 I hopped through ledgers, tiny and spry,

Plans took root beneath the sky,
Webhooks chimed and quotas reset,
Subscriptions snug in every net,
A rabbit cheers — code baked pie!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically describes the main change: adding a subscription billing system. It is directly related to the extensive changeset introducing subscription management, payment flows, and related infrastructure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/subscription

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@t0ng7u t0ng7u changed the title Feature/subscription ✨ feat: add subscription billing system Feb 2, 2026
@t0ng7u
t0ng7u requested a review from Copilot February 2, 2026 09:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Guard the subscriptions menu item when the callback is absent.

If showUserSubscriptionsModal is 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:

  1. The err != nil check on line 200 is redundant—if execution reaches the else if, err is already non-nil.

  2. 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 keeps ts consistently 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).Error
controller/relay.go (1)

118-123: Propagate generated RequestId into error responses.
When no RequestId is provided, responses will still use the empty requestId captured before GenRelayInfo. 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 CompleteSubscriptionOrder fails (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 from CompleteSubscriptionOrder is 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/topup instead 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 useMemo dependency array includes showUserSubscriptionsUserModal and other handler functions. Since these are defined inside the component without useCallback, they will be recreated on every render, causing the memoization to be ineffective.

This follows the existing pattern in the file (other handlers like showPromoteUserModal are also included), so it's consistent, but the columns will be recalculated on every render.

Consider wrapping the handlers with useCallback if 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 adding user?.id to useEffect dependencies.

The effect loads data based on user?.id but only depends on visible. 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.

formatDuration and formatResetPeriod are duplicated in SubscriptionPlansCard.jsx. Consider extracting these to a shared utility file to improve maintainability.

Comment thread controller/subscription_payment_creem.go
Comment thread controller/subscription_payment_epay.go Outdated
Comment thread controller/subscription_payment_stripe.go
Comment thread controller/subscription_payment_stripe.go
Comment thread controller/subscription.go
Comment thread service/quota.go
Comment thread web/src/components/table/subscriptions/SubscriptionsColumnDefs.jsx Outdated
Comment thread web/src/components/topup/index.jsx
Comment thread web/src/components/topup/modals/SubscriptionPurchaseModal.jsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/src/pages/Subscription/index.jsx
Comment thread web/src/components/topup/SubscriptionPlansCard.jsx Outdated
Comment thread controller/subscription.go Outdated
Comment thread model/db_time.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SubscriptionEpayNotify and SubscriptionEpayReturn. 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

Comment thread controller/subscription_payment_epay.go Outdated
Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
@t0ng7u

t0ng7u commented Feb 3, 2026

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit full review

@coderabbitai

coderabbitai Bot commented Feb 3, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Guard empty referenceId before locking and processing.

Without this, LockOrder("") is possible and CompleteSubscriptionOrder/Rechage get 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 normalizing payment_method casing 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 in NormalizeBillingPreference.

♻️ 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-parsing record.other for billing tags.

renderBillingTag re-parses JSON that the COST renderer already parsed, which can duplicate error logs and work. Consider passing isSubscription (or other) into renderBillingTag.

♻️ 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 required rule 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 adding user?.id to useEffect dependencies.

If the modal remains visible while the user prop changes (edge case), the subscriptions won't reload. The current pattern works because the modal is typically closed before switching users, but adding user?.id to 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, About use lazy() 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: Missing loadPlans in useEffect dependency array.

The useEffect calls loadPlans but doesn't include it in the dependency array. While this works because loadPlans is stable (no dependencies that would cause recreation), adding an ESLint disable comment or using useCallback would 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:

  1. If order.Insert() fails, the user never receives the link and won't complete payment
  2. The Stripe session expires if not used
  3. The webhook uses ClientReferenceID (referenceId) which matches the order's TradeNo

However, 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 "" == customerId uses Yoda-style comparison. While functionally correct, the more idiomatic Go style is customerId == "".

🔧 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 ExpireDueSubscriptions fails, the function returns early without attempting ResetDueSubscriptions or 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 updating updated_at when 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
 	}

Comment on lines +97 to +113
// 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,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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.

Comment on lines +41 to +63
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
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +110 to +158
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +160 to +239
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment thread model/db_time.go
Comment on lines +1 to +22
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +536 to +564
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +2622 to +2625
"订阅管理": "Управление подписками",
"订阅套餐管理": "Управление тарифами подписки",
"新建套餐": "Создать план",
"套餐": "План",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
"订阅管理": "Управление подписками",
"订阅套餐管理": "Управление тарифами подписки",
"新建套餐": "Создать план",
"套餐": "План",
"订阅管理": "Управление подписками",
"订阅套餐管理": "Управление планами подписки",
"新建套餐": "Создать план",
"套餐": "План",
🤖 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 "订阅管理": "Управление подписками".

Comment on lines +2652 to +2654
"有效期单位": "Единица срока",
"自定义秒数": "Пользовательские секунды",
"请输入秒数": "Введите количество секунд",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
"有效期单位": "Единица срока",
"自定义秒数": "Пользовательские секунды",
"请输入秒数": "Введите количество секунд",
"有效期单位": "Единица срока",
"自定义秒数": "Пользовательское количество секунд",
"请输入秒数": "Введите количество секунд",
🤖 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.

Comment on lines +2665 to +2667
"新增订阅": "Добавить подписку",
"暂无订阅记录": "Нет записей подписок",
"来源": "Источник",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +2672 to +2674
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?",
"绑定订阅套餐": "Привязать план подписки",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?",
"绑定订阅套餐": "Привязать план подписки",
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали льгот). Продолжить?",
"绑定订阅套餐": "Привязать план подписки",
🤖 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.

@Calcium-Ion
Calcium-Ion merged commit 9da3412 into main Feb 3, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Feb 22, 2026
@Calcium-Ion
Calcium-Ion deleted the feature/subscription branch March 17, 2026 09:25
ennnnny pushed a commit to ennnnny/new-api that referenced this pull request Mar 17, 2026
* 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.
@coderabbitai coderabbitai Bot mentioned this pull request Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants