Files
NianAIGC/backend/internal/postgres/refunds.go
T
2026-10-12 13:57:23 +08:00

534 lines
20 KiB
Go

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)