package app import ( "fmt" "net/http" "strings" "time" ) // The money view operations actually needs: what has been taken in, what is // still held on someone's behalf, what has been paid out, and whether the two // sides of the book still agree. Split across four pages it was impossible to // answer "how much of our balance is other people's money" — that is the // number this page exists for. func (a *App) adminFinanceOverview(w http.ResponseWriter, r *http.Request) { ctx := r.Context() days := 14 if value := strings.TrimSpace(r.URL.Query().Get("days")); value != "" { if parsed, err := fmt.Sscanf(value, "%d", &days); parsed != 1 || err != nil || days < 1 || days > 90 { days = 14 } } money := map[string]int64{ // 托管中 = 已经从主人那里收到、还没结算也没退的钱。这是平台账上"别人的钱"。 "escrowedCent": a.countOne(ctx, `SELECT COALESCE(SUM(gross_cent-refunded_cent),0) FROM pet_feed_tasks WHERE status IN ('ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`), "settledCent": a.countOne(ctx, `SELECT COALESCE(SUM(payout_cent),0) FROM pet_feed_tasks WHERE status='SETTLED'`), "platformFeeCent": a.countOne(ctx, `SELECT COALESCE(SUM(platform_fee_cent),0) FROM pet_feed_tasks WHERE status='SETTLED'`), "tipCent": a.countOne(ctx, `SELECT COALESCE(SUM(tip_cent),0) FROM pet_feed_tasks WHERE status IN ('SETTLED','COMPLETED','SERVING','ASSIGNED','ESCROWED')`), "walletBalanceCent": a.countOne(ctx, `SELECT COALESCE(SUM(available_cent),0) FROM wallet_accounts`), "pendingPayoutCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals WHERE status IN ('PENDING','APPROVED')`), "paidPayoutCent": a.countOne(ctx, `SELECT COALESCE(SUM(payout_cent),0) FROM withdrawals WHERE status='PAID'`), "orderPaidCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM orders WHERE status IN ('PAID','REFUNDING') AND deleted_at IS NULL`), "orderRefundedCent": a.countOne(ctx, `SELECT COALESCE(SUM(refunded_cent),0) FROM orders WHERE deleted_at IS NULL`), "feedRefundedCent": a.countOne(ctx, `SELECT COALESCE(SUM(refunded_cent),0) FROM pet_feed_tasks`), "unpaidOrderCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM orders WHERE status='CREATED' AND deleted_at IS NULL`), "platformOwesCent": a.countOne(ctx, `SELECT COALESCE(SUM(available_cent),0) FROM wallet_accounts`), "disputeHeldCent": a.countOne(ctx, `SELECT COALESCE(SUM(gross_cent-refunded_cent),0) FROM pet_feed_tasks WHERE status='DISPUTED'`), "ledgerMismatchUser": a.countOne(ctx, `SELECT COUNT(*) FROM (SELECT a.user_id FROM wallet_accounts a LEFT JOIN wallet_transactions t ON t.user_id=a.user_id GROUP BY a.user_id,a.available_cent HAVING a.available_cent<>COALESCE(SUM(t.amount_cent*t.direction),0)) AS drift`), } // 按产品看收入:会员、代喂、保证金各自多少,退了多少。 products := []map[string]any{} rows, err := a.db.QueryContext(ctx, `SELECT product_type,COUNT(*),COALESCE(SUM(amount_cent),0),COALESCE(SUM(refunded_cent),0) FROM orders WHERE status IN ('PAID','REFUNDING','REFUNDED') AND deleted_at IS NULL GROUP BY product_type`) if err == nil { defer rows.Close() for rows.Next() { var productType string var count, amount, refunded int64 if rows.Scan(&productType, &count, &amount, &refunded) == nil { products = append(products, map[string]any{ "productType": productType, "orderCount": count, "amountCent": amount, "refundedCent": refunded, }) } } } // 按日趋势:收进来的和退出去的放在一起看,才能看出某天是不是在净流出。 trend := []map[string]any{} trendRows, err := a.db.QueryContext(ctx, `SELECT DATE(paid_at) AS day,COALESCE(SUM(amount_cent),0),COUNT(*) FROM orders WHERE paid_at IS NOT NULL AND paid_at>=DATE_SUB(CURDATE(),INTERVAL ? DAY) AND deleted_at IS NULL GROUP BY DATE(paid_at) ORDER BY day`, days) if err == nil { defer trendRows.Close() for trendRows.Next() { var day time.Time var amount, count int64 if trendRows.Scan(&day, &amount, &count) == nil { trend = append(trend, map[string]any{ "date": day.Format("2006-01-02"), "amountCent": amount, "orderCount": count, }) } } } reply(w, map[string]any{ "money": money, "products": products, "trend": trend, "days": days, "withdrawEnabled": a.configBool(ctx, "pet.withdraw_enabled", false), "paymentMode": a.configPlain(ctx, "payment.mode", "sandbox"), }) } // adminOrderRefunds lists what has already been sent back on one order. func (a *App) adminOrderRefunds(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "编号无效") return } rows, queryErr := a.db.QueryContext(r.Context(), `SELECT f.id,f.amount_cent,f.channel,f.reason,f.created_at, COALESCE(admin.username,'') FROM order_refunds f LEFT JOIN admin_users admin ON admin.id=f.handled_by WHERE f.order_id=? ORDER BY f.id DESC`, id) if queryErr != nil { fail(w, http.StatusInternalServerError, 50001, "查询退款记录失败") return } defer rows.Close() items := []map[string]any{} for rows.Next() { var refundID, amount int64 var channel, reason, operator string var created time.Time if rows.Scan(&refundID, &amount, &channel, &reason, &created, &operator) != nil { continue } items = append(items, map[string]any{ "id": refundID, "amountCent": amount, "channel": channel, "reason": reason, "operator": operator, "createdAt": created.Format(time.RFC3339), }) } reply(w, map[string]any{"items": items}) } // adminRefundOrder is the one refund entry point for every product. A partial // refund leaves what it bought alone; only refunding the last cent revokes it, // because half a membership is still a membership. func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { fail(w, http.StatusBadRequest, 20001, "订单编号无效") return } var req struct { AmountCent int64 `json:"amountCent"` Reason string `json:"reason"` Offline bool `json:"offline"` } if r.ContentLength > 0 && decode(r, &req) != nil { fail(w, http.StatusBadRequest, 20001, "参数错误") return } req.Reason = strings.TrimSpace(req.Reason) if req.Reason == "" { req.Reason = "客服操作退款" } var userID, productID int64 var amount, refunded int var orderNo, providerOrderNo, status, productType string if a.db.QueryRowContext(r.Context(), `SELECT user_id,product_type,product_id,amount_cent,COALESCE(refunded_cent,0), order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL`, id). Scan(&userID, &productType, &productID, &amount, &refunded, &orderNo, &providerOrderNo, &status) != nil { fail(w, http.StatusNotFound, 30001, "订单不存在") return } if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" && status != "REFUNDED" { fail(w, http.StatusBadRequest, 20001, "只有已支付的订单可以退款") return } remaining := int64(amount - refunded) if remaining <= 0 { fail(w, http.StatusBadRequest, 20001, "这笔订单已经退完了") return } refundAmount := req.AmountCent if refundAmount <= 0 { refundAmount = remaining } if refundAmount > remaining { fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("最多还能退 %.2f 元", float64(remaining)/100)) return } live := a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" channel := "gateway" if req.Offline || providerOrderNo == "" || strings.HasPrefix(providerOrderNo, "sandbox:") || !live { channel = "offline" } refundNo := fmt.Sprintf("order:%d:%d", id, time.Now().UnixMilli()) if channel == "gateway" { if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, refundNo, int(refundAmount)); err != nil { a.audit(r, "refund_failed", "order", id, map[string]any{"amountCent": refundAmount, "error": err.Error()}) fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,未记账,可以重试或改为线下退款登记") return } } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { fail(w, http.StatusInternalServerError, 50001, "退款记账失败") return } defer func() { _ = tx.Rollback() }() if _, err = tx.ExecContext(r.Context(), `INSERT INTO order_refunds(order_id,refund_no,user_id,amount_cent,channel,reason,handled_by) VALUES(?,?,?,?,?,?,?)`, id, refundNo, userID, refundAmount, channel, req.Reason, current(r).ID); err != nil { fail(w, http.StatusInternalServerError, 50001, "退款记账失败") return } full := int64(refunded)+refundAmount >= int64(amount) newStatus := status if full { newStatus = "REFUNDED" } if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`, refundAmount, newStatus, id); err != nil { fail(w, http.StatusInternalServerError, 50001, "退款记账失败") return } // 退完才收回买到的东西:半份会员仍然是会员,代喂任务同理。 if full { if err = a.revokePaidOrderTx(r.Context(), tx, id, userID, productType, productID, int(refundAmount)); err != nil { fail(w, http.StatusBadRequest, 20001, err.Error()) return } } if tx.Commit() != nil { fail(w, http.StatusInternalServerError, 50001, "退款记账失败") return } a.audit(r, "refund", "order", id, map[string]any{ "amountCent": refundAmount, "channel": channel, "full": full, "reason": req.Reason, "refundNo": refundNo, }) a.notifyUser(r.Context(), userID, "system", "订单已退款", fmt.Sprintf("订单 %s 已退款 %.2f 元:%s", orderNo, float64(refundAmount)/100, req.Reason), "order", id) reply(w, map[string]any{ "success": true, "amountCent": refundAmount, "channel": channel, "refundedCent": int64(refunded) + refundAmount, "remainingCent": remaining - refundAmount, "full": full, }) }