feat(payment): order safety protection for provider/plan CRUD

Backend (matching sub2apipay behavior):
- UpdateProviderInstance: block credential changes (key/secret/password
  fields) when instance has PENDING/PAID/RECHARGING orders (409 Conflict)
- DeleteProviderInstance: block deletion when pending orders exist (409)
- DeletePlan: block deletion when pending orders exist (409)
- Non-credential changes (name/enabled/sort) still allowed with pending orders

Frontend:
- Filter empty config values before sending (avoid overwriting existing
  credentials when editing a provider without re-entering all secrets)
This commit is contained in:
erio
2026-04-06 19:31:26 +08:00
parent b0025ed25d
commit 91413cf410
2 changed files with 83 additions and 1 deletions
@@ -8,6 +8,7 @@ import (
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
"github.com/Wei-Shaw/sub2api/internal/payment"
@@ -243,6 +244,45 @@ func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*db
return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx)
}
// pendingOrderStatuses are order statuses considered "in progress" —
// modifying provider credentials or deleting a provider/plan is blocked
// while orders in these states exist.
var pendingOrderStatuses = []string{
payment.OrderStatusPending,
payment.OrderStatusPaid,
payment.OrderStatusRecharging,
}
// sensitiveConfigPatterns are substrings that identify credential fields.
// Changes to these fields are blocked when the provider has pending orders.
var sensitiveConfigPatterns = []string{"key", "pkey", "secret", "private", "password"}
func isSensitiveConfigField(fieldName string) bool {
lower := strings.ToLower(fieldName)
for _, p := range sensitiveConfigPatterns {
if strings.Contains(lower, p) {
return true
}
}
return false
}
func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
return s.entClient.PaymentOrder.Query().
Where(
paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
paymentorder.StatusIn(pendingOrderStatuses...),
).Count(ctx)
}
func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
return s.entClient.PaymentOrder.Query().
Where(
paymentorder.PlanIDEQ(planID),
paymentorder.StatusIn(pendingOrderStatuses...),
).Count(ctx)
}
func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
enc, err := s.encryptConfig(req.Config)
if err != nil {
@@ -256,6 +296,27 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
}
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
// Check credential change safety when config is being modified
if req.Config != nil {
hasSensitive := false
for k := range req.Config {
if isSensitiveConfigField(k) && req.Config[k] != "" {
hasSensitive = true
break
}
}
if hasSensitive {
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return nil, fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return nil, infraerrors.Conflict("PENDING_ORDERS",
fmt.Sprintf("this instance has %d in-progress orders; changing credentials may break payment callbacks — wait for orders to complete or disable the instance first", count))
}
}
}
u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
if req.Name != nil {
u.SetName(*req.Name)
@@ -286,6 +347,14 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
count, err := s.countPendingOrders(ctx, id)
if err != nil {
return fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return infraerrors.Conflict("PENDING_ORDERS",
fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
}
return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
}
@@ -367,6 +436,14 @@ func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req Upd
}
func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
count, err := s.countPendingOrdersByPlan(ctx, id)
if err != nil {
return fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return infraerrors.Conflict("PENDING_ORDERS",
fmt.Sprintf("this plan has %d in-progress orders and cannot be deleted — wait for orders to complete first", count))
}
return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx)
}
+6 -1
View File
@@ -3070,6 +3070,11 @@ function openEditProvider(provider: ProviderInstance) {
async function handleSaveProvider() {
providerSaving.value = true
try {
// Filter out empty config values to avoid overwriting existing credentials
const filteredConfig: Record<string, string> = {}
for (const [k, v] of Object.entries(providerConfig)) {
if (v && v.trim()) filteredConfig[k] = v
}
// Build camelCase payload matching backend struct
const payload: any = {
providerKey: providerForm.provider_key,
@@ -3077,7 +3082,7 @@ async function handleSaveProvider() {
supportedTypes: providerForm.supported_types,
enabled: providerForm.enabled,
refundEnabled: providerForm.refund_enabled,
config: { ...providerConfig },
config: filteredConfig,
}
if (editingProvider.value) { await adminAPI.payment.updateProvider(editingProvider.value.id, payload) }
else { await adminAPI.payment.createProvider(payload) }