package postgres import ( "context" "database/sql" "encoding/json" "strings" "time" "unicode" "unicode/utf8" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" ) const refundColumns = `r.id, r.order_id, r.organization_id, r.operator_id, COALESCE(u.display_name, ''), r.request_key, r.mch_id, r.transaction_id, COALESCE(r.provider_id, ''), r.amount_fen, r.total_fen, r.currency, r.reason, r.status, r.failure_reason, r.created_at, r.updated_at, r.succeeded_at` const refundSelect = `SELECT ` + refundColumns + ` FROM public.payment_refunds r LEFT JOIN public.platform_users u ON u.id = r.operator_id ` const adminOrdersSQL = `SELECT payment_orders.id, payment_orders.organization_id, payment_orders.account_id, payment_orders.request_key, payment_orders.mch_id, payment_orders.app_id, payment_orders.amount_fen, payment_orders.currency, payment_orders.status, COALESCE(payment_orders.code_url, ''), COALESCE(payment_orders.transaction_id, ''), payment_orders.created_at, payment_orders.updated_at, payment_orders.expires_at, payment_orders.paid_at, COALESCE(org.name, ''), COALESCE(account.display_name, ''), COALESCE(refunds.refunded_fen, 0), COALESCE(refunds.refunding_fen, 0) FROM public.payment_orders JOIN public.platform_organizations org ON org.id = payment_orders.organization_id JOIN public.platform_users account ON account.id = payment_orders.account_id LEFT JOIN LATERAL ( SELECT sum(amount_fen) FILTER (WHERE status = 'success') AS refunded_fen, sum(amount_fen) FILTER (WHERE status IN ('pending', 'processing', 'abnormal')) AS refunding_fen FROM public.payment_refunds WHERE order_id = payment_orders.id ) refunds ON true WHERE ($1::text = '' OR payment_orders.organization_id = $1::text) ORDER BY payment_orders.created_at DESC, payment_orders.id DESC LIMIT $2::integer OFFSET $3::integer` // The caller requests limit+1 to discover whether another page exists. func (db *Database) ListAdminPaymentOrders(ctx context.Context, organizationID string, limit, offset int) ([]payments.AdminOrder, error) { q, err := db.paymentQuerier() if err != nil { return nil, err } if offset < 0 { return nil, payments.ErrConflict } rows, err := q.Query(ctx, adminOrdersSQL, organizationID, boundedAdminOrdersLimit(limit), offset) if err != nil { return nil, paymentSQLError("list admin payment orders", err) } defer rows.Close() out := make([]payments.AdminOrder, 0) for rows.Next() { var item payments.AdminOrder var paidAt sql.NullTime if err := rows.Scan(&item.ID, &item.OrganizationID, &item.AccountID, &item.RequestKey, &item.MchID, &item.AppID, &item.AmountFen, &item.Currency, &item.Status, &item.CodeURL, &item.TransactionID, &item.CreatedAt, &item.UpdatedAt, &item.ExpiresAt, &paidAt, &item.OrganizationName, &item.AccountName, &item.RefundedFen, &item.RefundingFen); err != nil { return nil, paymentSQLError("scan admin payment order", err) } if paidAt.Valid { item.PaidAt = &paidAt.Time } item.CodeURL = "" item.RefundableFen = item.AmountFen - item.RefundedFen - item.RefundingFen if item.Status != payments.StatusPaid || item.RefundableFen < 0 { item.RefundableFen = 0 } out = append(out, item) } return out, paymentSQLError("read admin payment orders", rows.Err()) } func boundedAdminOrdersLimit(limit int) int { if limit < 1 { return 1 } if limit > 101 { return 101 } return limit } func (db *Database) CreatePaymentRefund(ctx context.Context, input payments.Refund) (payments.Refund, error) { if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { return payments.Refund{}, payments.ErrUnavailable } if !validPaymentKey(input.ID, 32) || !validPaymentKey(input.RequestKey, 64) || !validPaymentKey(input.OrderID, 32) || input.OperatorID == "" || input.AmountFen <= 0 || len(input.Reason) > 80 { return payments.Refund{}, payments.ErrConflict } tx, err := db.transactions.Begin(ctx) if err != nil { return payments.Refund{}, paymentSQLError("begin refund reservation", err) } defer tx.Rollback(ctx) order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, input.OrderID) if err != nil { return payments.Refund{}, paymentSQLError("lock original payment", err) } if !found { return payments.Refund{}, payments.ErrNotFound } if order.Status != payments.StatusPaid { return payments.Refund{}, payments.ErrConflict } operatorOK, err := queryRefundOperator(ctx, tx, input.OperatorID) if err != nil { return payments.Refund{}, paymentSQLError("verify refund operator", err) } if !operatorOK { return payments.Refund{}, payments.ErrConflict } previous, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.order_id = $1 AND r.request_key = $2`, order.ID, input.RequestKey) if err != nil { return payments.Refund{}, paymentSQLError("read refund request", err) } if found { if previous.OperatorID != input.OperatorID || previous.AmountFen != input.AmountFen || previous.Reason != input.Reason { return payments.Refund{}, payments.ErrConflict } if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit refund replay", err) } return previous, nil } used, err := queryRefundUsed(ctx, tx, order.ID, "") if err != nil { return payments.Refund{}, paymentSQLError("check refundable balance", err) } if used > order.AmountFen || input.AmountFen > order.AmountFen-used { return payments.Refund{}, payments.ErrInvalidAmount } created, found, err := queryRefund(ctx, tx, `INSERT INTO public.payment_refunds (id, order_id, organization_id, operator_id, request_key, mch_id, transaction_id, amount_fen, total_fen, currency, reason) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING `+refundColumnsNoJoin, input.ID, order.ID, order.OrganizationID, input.OperatorID, input.RequestKey, order.MchID, order.TransactionID, input.AmountFen, order.AmountFen, order.Currency, input.Reason) if err != nil { return payments.Refund{}, paymentSQLError("create refund reservation", err) } if !found { return payments.Refund{}, payments.ErrConflict } if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit refund reservation", err) } return created, nil } const refundColumnsNoJoin = `id, order_id, organization_id, operator_id, ''::text, request_key, mch_id, transaction_id, COALESCE(provider_id, ''), amount_fen, total_fen, currency, reason, status, failure_reason, created_at, updated_at, succeeded_at` func queryRefundOperator(ctx context.Context, q Querier, operatorID string) (bool, error) { rows, err := q.Query(ctx, `SELECT 1 FROM public.platform_users WHERE id = $1 AND role = 'super_admin' AND status = 'active'`, operatorID) if err != nil { return false, err } defer rows.Close() if !rows.Next() { return false, rows.Err() } var one int if err := rows.Scan(&one); err != nil { return false, err } return one == 1, rows.Err() } func queryRefundUsed(ctx context.Context, q Querier, orderID, excludeID string) (int64, error) { rows, err := q.Query(ctx, `SELECT COALESCE(sum(amount_fen), 0)::bigint FROM public.payment_refunds WHERE order_id = $1 AND id <> $2 AND status IN ('pending', 'processing', 'abnormal', 'success')`, orderID, excludeID) if err != nil { return 0, err } defer rows.Close() if !rows.Next() { return 0, rows.Err() } var used int64 if err := rows.Scan(&used); err != nil { return 0, err } return used, rows.Err() } func (db *Database) GetPaymentRefund(ctx context.Context, id string) (payments.Refund, error) { q, err := db.paymentQuerier() if err != nil { return payments.Refund{}, err } item, found, err := queryRefund(ctx, q, refundSelect+` WHERE r.id = $1`, id) if err != nil { return payments.Refund{}, paymentSQLError("get refund", err) } if !found { return payments.Refund{}, payments.ErrNotFound } return item, nil } func (db *Database) ListPaymentRefunds(ctx context.Context, orderID string) ([]payments.Refund, error) { q, err := db.paymentQuerier() if err != nil { return nil, err } return queryRefunds(ctx, q, refundSelect+` WHERE r.order_id = $1 ORDER BY r.created_at DESC, r.id DESC`, orderID) } func (db *Database) PendingPaymentRefunds(ctx context.Context, limit int) ([]payments.Refund, error) { q, err := db.paymentQuerier() if err != nil { return nil, err } return queryRefunds(ctx, q, refundSelect+` WHERE r.status IN ('pending', 'processing', 'abnormal') ORDER BY r.updated_at, r.created_at, r.id LIMIT $1`, boundedPaymentLimit(limit)) } func (db *Database) TouchPaymentRefund(ctx context.Context, id string) (payments.Refund, error) { q, err := db.paymentQuerier() if err != nil { return payments.Refund{}, err } item, found, err := queryRefund(ctx, q, `UPDATE public.payment_refunds SET updated_at = now() WHERE id = $1 AND status IN ('pending', 'processing', 'abnormal') RETURNING `+refundColumnsNoJoin, id) if err != nil { return payments.Refund{}, paymentSQLError("touch refund", err) } if found { return item, nil } return db.GetPaymentRefund(ctx, id) } // Reject only an unsubmitted request. The original payment lock serializes // reservation release with new requests and late provider notifications. func (db *Database) RejectPaymentRefund(ctx context.Context, id, reason string) (payments.Refund, error) { if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { return payments.Refund{}, payments.ErrUnavailable } if !validPaymentKey(id, 32) || reason == "" || len(reason) > 160 || !utf8.ValidString(reason) || strings.IndexFunc(reason, unicode.IsControl) >= 0 { return payments.Refund{}, payments.ErrConflict } tx, err := db.transactions.Begin(ctx) if err != nil { return payments.Refund{}, paymentSQLError("begin refund rejection", err) } defer tx.Rollback(ctx) initial, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1`, id) if err != nil { return payments.Refund{}, paymentSQLError("read refund for rejection", err) } if !found { return payments.Refund{}, payments.ErrNotFound } if _, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, initial.OrderID); err != nil { return payments.Refund{}, paymentSQLError("lock original payment for rejection", err) } else if !found { return payments.Refund{}, payments.ErrNotFound } item, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1 FOR UPDATE OF r`, id) if err != nil { return payments.Refund{}, paymentSQLError("lock refund for rejection", err) } if !found { return payments.Refund{}, payments.ErrNotFound } if item.OrderID != initial.OrderID { return payments.Refund{}, payments.ErrConflict } if item.Status == payments.RefundRejected { if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit rejected refund replay", err) } return item, nil } if item.Status != payments.RefundPending || item.ProviderID != "" { return payments.Refund{}, payments.ErrConflict } item, found, err = queryRefund(ctx, tx, `UPDATE public.payment_refunds SET status = 'rejected', failure_reason = $2, updated_at = now() WHERE id = $1 AND status = 'pending' AND provider_id IS NULL RETURNING `+refundColumnsNoJoin, id, reason) if err != nil { return payments.Refund{}, paymentSQLError("reject refund", err) } if !found { return payments.Refund{}, payments.ErrConflict } if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit refund rejection", err) } return item, nil } func (db *Database) RecordPaymentRefund(ctx context.Context, remote payments.RefundTransaction) (payments.Refund, error) { if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { return payments.Refund{}, payments.ErrUnavailable } if !validPaymentKey(remote.ID, 32) || !validPaymentKey(remote.OrderID, 32) || remote.MchID == "" || remote.TransactionID == "" || remote.ProviderID == "" || remote.Currency != "CNY" || remote.AmountFen <= 0 || remote.TotalFen <= 0 || (remote.Status != payments.RefundPending && remote.Status != payments.RefundProcessing && remote.Status != payments.RefundSuccess && remote.Status != payments.RefundClosed && remote.Status != payments.RefundAbnormal) { return payments.Refund{}, payments.ErrVerification } tx, err := db.transactions.Begin(ctx) if err != nil { return payments.Refund{}, paymentSQLError("begin refund reconciliation", err) } defer tx.Rollback(ctx) order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, remote.OrderID) if err != nil { return payments.Refund{}, paymentSQLError("lock original payment", err) } if !found { return payments.Refund{}, payments.ErrNotFound } item, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1 FOR UPDATE OF r`, remote.ID) if err != nil { return payments.Refund{}, paymentSQLError("lock refund", err) } if !found { return payments.Refund{}, payments.ErrNotFound } if order.Status != payments.StatusPaid || item.OrderID != order.ID || item.OrganizationID != order.OrganizationID || item.MchID != order.MchID || item.TransactionID != order.TransactionID || item.TotalFen != order.AmountFen || item.ID != remote.ID || item.OrderID != remote.OrderID || item.MchID != remote.MchID || item.TransactionID != remote.TransactionID || item.AmountFen != remote.AmountFen || item.TotalFen != remote.TotalFen || item.Currency != remote.Currency || (item.ProviderID != "" && item.ProviderID != remote.ProviderID) { return payments.Refund{}, payments.ErrVerification } if item.Status == payments.RefundSuccess { if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit refund replay", err) } return item, nil } if remote.Status == payments.RefundSuccess { // A provider-confirmed refund can arrive after a former CLOSED // reservation was released and another refund was requested. Pending // requests are only local reservations; they cannot invalidate money // already returned by WeChat. Count completed refunds for this check. used, err := queryRefundSucceeded(ctx, tx, order.ID, item.ID) if err != nil { return payments.Refund{}, paymentSQLError("check total refunded", err) } if used > order.AmountFen || item.AmountFen > order.AmountFen-used { return payments.Refund{}, payments.ErrConflict } if err := debitRefundWallet(ctx, tx, item, remote.ProviderID); err != nil { return payments.Refund{}, err } } // Once closed, a delayed genuine success may still arrive. Other stale states // never reopen it; success itself can never be reversed locally. status := remote.Status if item.Status == payments.RefundClosed && status != payments.RefundSuccess { status = payments.RefundClosed } if item.Status == payments.RefundRejected && status != payments.RefundSuccess && status != payments.RefundProcessing && status != payments.RefundAbnormal { status = payments.RefundRejected } if item.Status == payments.RefundProcessing && status == payments.RefundPending { status = payments.RefundProcessing } if item.Status == payments.RefundAbnormal && status == payments.RefundPending { status = payments.RefundAbnormal } when := remote.SuccessTime if status == payments.RefundSuccess && when.IsZero() { when = time.Now().UTC() } item, found, err = queryRefund(ctx, tx, `UPDATE public.payment_refunds SET status = $2, provider_id = $3, succeeded_at = CASE WHEN $2 = 'success' THEN $4::timestamptz ELSE NULL END, failure_reason = CASE WHEN $2 = 'rejected' THEN failure_reason ELSE '' END, updated_at = now() WHERE id = $1 RETURNING `+refundColumnsNoJoin, item.ID, status, remote.ProviderID, nullableRefundTime(status, when)) if err != nil { return payments.Refund{}, paymentSQLError("record refund status", err) } if !found { return payments.Refund{}, payments.ErrConflict } if err := tx.Commit(ctx); err != nil { return payments.Refund{}, paymentSQLError("commit refund reconciliation", err) } return item, nil } func queryRefundSucceeded(ctx context.Context, q Querier, orderID, excludeID string) (int64, error) { rows, err := q.Query(ctx, `SELECT COALESCE(sum(amount_fen), 0)::bigint FROM public.payment_refunds WHERE order_id = $1 AND id <> $2 AND status = 'success'`, orderID, excludeID) if err != nil { return 0, err } defer rows.Close() if !rows.Next() { return 0, rows.Err() } var amount int64 if err := rows.Scan(&amount); err != nil { return 0, err } return amount, rows.Err() } func nullableRefundTime(status string, when time.Time) any { if status == payments.RefundSuccess { return when } return nil } func debitRefundWallet(ctx context.Context, tx Transaction, item payments.Refund, providerID string) error { metadata, err := json.Marshal(map[string]string{ "source": "wechat_refund", "paymentOrderId": item.OrderID, "refundId": item.ID, "transactionId": item.TransactionID, "providerRefundId": providerID, }) if err != nil { return err } rows, err := tx.Query(ctx, `INSERT INTO public.billing_wallets(organization_id) VALUES ($1) ON CONFLICT (organization_id) DO NOTHING RETURNING organization_id`, item.OrganizationID) if err != nil { return paymentSQLError("ensure refund wallet", err) } rows.Close() rows, err = tx.Query(ctx, `UPDATE public.billing_wallets SET balance_fen = balance_fen - $2, updated_at = now() WHERE organization_id = $1 RETURNING balance_fen`, item.OrganizationID, item.AmountFen) if err != nil { return paymentSQLError("debit refund wallet", err) } if !rows.Next() { rows.Close() return payments.ErrConflict } var balance int64 if err := rows.Scan(&balance); err != nil { rows.Close() return paymentSQLError("scan refund wallet", err) } rows.Close() key := "wechat-refund:" + item.MchID + ":" + item.ID rows, err = tx.Query(ctx, `INSERT INTO public.billing_ledger (id, organization_id, kind, delta_fen, balance_after_fen, currency, idempotency_key, description, metadata) VALUES ($1, $2, 'recharge_refund', $3, $4, 'CNY', $5, '微信充值退款', $6::jsonb) RETURNING id`, "wechat-refund-entry:"+item.ID, item.OrganizationID, -item.AmountFen, balance, key, json.RawMessage(metadata)) if err != nil { return paymentSQLError("record refund wallet ledger", err) } if !rows.Next() { rows.Close() return payments.ErrConflict } var id string if err := rows.Scan(&id); err != nil { rows.Close() return paymentSQLError("scan refund ledger", err) } rows.Close() if id != "wechat-refund-entry:"+item.ID { return payments.ErrConflict } return nil } func queryRefund(ctx context.Context, q Querier, statement string, args ...any) (payments.Refund, bool, error) { rows, err := q.Query(ctx, statement, args...) if err != nil { return payments.Refund{}, false, err } defer rows.Close() if !rows.Next() { return payments.Refund{}, false, rows.Err() } item, err := scanRefund(rows) if err != nil { return payments.Refund{}, false, err } return item, true, rows.Err() } func queryRefunds(ctx context.Context, q Querier, statement string, args ...any) ([]payments.Refund, error) { rows, err := q.Query(ctx, statement, args...) if err != nil { return nil, paymentSQLError("list refunds", err) } defer rows.Close() out := make([]payments.Refund, 0) for rows.Next() { item, err := scanRefund(rows) if err != nil { return nil, paymentSQLError("scan refund", err) } out = append(out, item) } return out, paymentSQLError("read refunds", rows.Err()) } func scanRefund(rows Rows) (payments.Refund, error) { var item payments.Refund var succeeded sql.NullTime if err := rows.Scan(&item.ID, &item.OrderID, &item.OrganizationID, &item.OperatorID, &item.OperatorName, &item.RequestKey, &item.MchID, &item.TransactionID, &item.ProviderID, &item.AmountFen, &item.TotalFen, &item.Currency, &item.Reason, &item.Status, &item.FailureReason, &item.CreatedAt, &item.UpdatedAt, &succeeded); err != nil { return payments.Refund{}, err } if succeeded.Valid { item.SucceededAt = &succeeded.Time } return item, nil } var _ payments.RefundRepository = (*Database)(nil)