实现分销网后台基础功能与独立前端

This commit is contained in:
yml2213
2026-09-05 10:52:36 +08:00
parent 85332df2bd
commit 2ffa7a89b1
24 changed files with 6122 additions and 25 deletions
@@ -0,0 +1,179 @@
package distribution
import "time"
type SiteDTO struct {
ID uint64 `json:"id"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateSiteRequest struct {
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
Domain string `json:"domain" binding:"required,max=255"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
}
type UpdateSiteRequest struct {
SiteName *string `json:"site_name"`
Domain *string `json:"domain"`
Status *string `json:"status"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
}
type RuleDTO struct {
ID uint64 `json:"id"`
SiteID *uint64 `json:"site_id,omitempty"`
Mode string `json:"mode"`
Version int `json:"version"`
Status string `json:"status"`
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
PlatformSellRatio float64 `json:"platform_sell_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
DistributorARatio float64 `json:"distributor_a_ratio"`
DistributorBRatio float64 `json:"distributor_b_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
ServiceFeeUnit string `json:"service_fee_unit"`
PaymentFeeBPS int64 `json:"payment_fee_bps"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
EffectiveFrom time.Time `json:"effective_from"`
EffectiveTo *time.Time `json:"effective_to,omitempty"`
CreatedBy *uint64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRuleRequest struct {
SiteID *uint64 `json:"site_id"`
Mode string `json:"mode" binding:"required"`
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
PlatformSellRatio float64 `json:"platform_sell_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
DistributorARatio float64 `json:"distributor_a_ratio"`
DistributorBRatio float64 `json:"distributor_b_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
ServiceFeeUnit string `json:"service_fee_unit" binding:"required"`
PaymentFeeBPS int64 `json:"payment_fee_bps"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
EffectiveFrom time.Time `json:"effective_from"`
}
type UpdateRuleStatusRequest struct {
Status string `json:"status" binding:"required,oneof=draft active disabled"`
}
type DistributionOrderDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
SiteID uint64 `json:"site_id"`
ListingID uint64 `json:"listing_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
RefundStatus string `json:"refund_status"`
SettlementStatus string `json:"settlement_status"`
DistributionMode string `json:"distribution_mode"`
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
NetPaidAmountCent int64 `json:"net_paid_amount_cent"`
PaymentFeeCent int64 `json:"payment_fee_cent"`
ServiceFeeCent int64 `json:"service_fee_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
CreatedAt time.Time `json:"created_at"`
}
type DistributionOrderListQuery struct {
SiteID uint64
Kind string
Status string
Page int
Size int
}
type DistributionOrderPage struct {
Items []DistributionOrderDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type AccountDisplayDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
ListingID uint64 `json:"listing_id"`
DisplayStatus string `json:"display_status"`
}
type UpdateAccountDisplayRequest struct {
DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"`
}
type PaymentChannelDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
ConfigID uint64 `json:"config_id"`
Priority int `json:"priority"`
Status string `json:"status"`
}
type BindPaymentChannelRequest struct {
ConfigID uint64 `json:"config_id" binding:"required"`
Priority int `json:"priority"`
}
type SettlementBatchDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
BatchNo string `json:"batch_no"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
TotalAmountCent int64 `json:"total_amount_cent"`
Status string `json:"status"`
}
type CreateSettlementBatchRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
PeriodStart time.Time `json:"period_start" binding:"required"`
PeriodEnd time.Time `json:"period_end" binding:"required"`
}
type SnapshotPartyRequest struct {
PartyRole string `json:"party_role" binding:"required"`
PartyType string `json:"party_type" binding:"required"`
PartyID uint64 `json:"party_id"`
Ratio float64 `json:"ratio"`
IncomeAmountCent int64 `json:"income_amount_cent"`
CostAmountCent int64 `json:"cost_amount_cent"`
}
type CreateFinancialSnapshotRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
RuleVersionID uint64 `json:"rule_version_id" binding:"required"`
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
Parties []SnapshotPartyRequest `json:"parties"`
}
@@ -0,0 +1,343 @@
package distribution
import (
"errors"
"net/http"
"strconv"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct{ service *Service }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func (h *Handler) ListSites(c *gin.Context) {
items, err := h.service.ListSites(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) GetSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindSite(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateSite(c *gin.Context) {
var req CreateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.CreateSite(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListRules(c *gin.Context) {
var siteID *uint64
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "site_id 不正确")
return
}
siteID = &id
}
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateRule(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销规则参数不正确")
return
}
item, err := h.service.CreateRule(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateRuleStatus(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateRuleStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "规则状态不正确")
return
}
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListOrders(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID < 0 {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: uint64(siteID), Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) FindOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindOrder(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req CreateFinancialSnapshotRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销财务快照参数不正确")
return
}
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) ListAccountDisplays(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
if siteID == 0 {
response.BadRequest(c, "site_id 不正确")
}
return
}
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAccountDisplayRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "账号展示状态不正确")
return
}
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListPaymentChannels(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) BindPaymentChannel(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
var req BindPaymentChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "支付通道参数不正确")
return
}
item, err := h.service.BindPaymentChannel(c.Request.Context(), uint64(siteID), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListSettlementBatches(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateSettlementBatch(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateSettlementBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结算周期参数不正确")
return
}
item, err := h.service.CreateSettlementBatch(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.MarkSettlementBatch(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
func parseQueryUint(c *gin.Context, key string) int64 {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, key+" 不正确")
return -1
}
return int64(id)
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok && id > 0
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
response.NotFound(c, "分销资源不存在")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "分销服务暂时不可用")
default:
response.RecordError(c, err)
response.Error(c, http.StatusInternalServerError, "distribution_error", "分销服务暂时不可用")
}
}
@@ -0,0 +1,162 @@
package distribution
import (
"errors"
"math"
)
const (
ModeTwoPartyDistributorReceive = "two_party_distributor_receive"
ModeTwoPartyPlatformReceive = "two_party_platform_receive"
ModeThreeParty = "three_party"
ServiceFeeUnitPercent = "percent"
ServiceFeeUnitFixedAmount = "fixed_amount"
ServiceFeeUnitRatio = "ratio"
)
var (
ErrInvalidAmount = errors.New("invalid distribution amount")
ErrInvalidRate = errors.New("invalid distribution rate")
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
ErrUnsupportedMode = errors.New("unsupported distribution mode")
)
// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。
type Rule struct {
Mode string
PlatformReceiveRatio float64
PlatformSellRatio float64
DistributorReceiveRatio float64
DistributorSellRatio float64
DistributorARatio float64
DistributorBRatio float64
ServiceFeeValue float64
ServiceFeeUnit string
PaymentFeeBPS int64
TaxEnabled bool
TaxRateBPS int64
}
// SettlementInput 使用支付单和退款单的整数分快照计算分销费用。
type SettlementInput struct {
GrossPaidAmountCent int64
RefundAmountCent int64
MarkupAmountCent int64
GrossProfitByParty []PartyProfitInput
}
type PartyProfitInput struct {
PartyRole string
IncomeAmountCent int64
CostAmountCent int64
}
type PartyTaxResult struct {
PartyRole string `json:"party_role"`
GrossProfitCent int64 `json:"gross_profit_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
}
type SettlementResult struct {
GrossPaidAmountCent int64
RefundAmountCent int64
NetPaidAmountCent int64
PaymentFeeCent int64
ServiceFeeCent int64
MarkupAmountCent int64
SplitBaseAmountCent int64
TaxAmountCent int64
TaxByParty []PartyTaxResult
}
// DefaultRule 返回文档中约定的第一版默认值;调用方应将其写入规则表而非写死到业务流程。
func DefaultRule(mode string) (Rule, error) {
base := Rule{
Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio,
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
}
switch mode {
case ModeTwoPartyDistributorReceive:
base.DistributorReceiveRatio = 3.5
base.PlatformSellRatio = 4
case ModeTwoPartyPlatformReceive:
base.PlatformReceiveRatio = 3.5
base.DistributorSellRatio = 4
case ModeThreeParty:
base.DistributorARatio = 3.5
base.DistributorBRatio = 4
default:
return Rule{}, ErrUnsupportedMode
}
return base, nil
}
// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
return SettlementResult{}, ErrInvalidAmount
}
if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 {
return SettlementResult{}, ErrInvalidRate
}
if rule.ServiceFeeUnit != ServiceFeeUnitPercent && rule.ServiceFeeUnit != ServiceFeeUnitFixedAmount && rule.ServiceFeeUnit != ServiceFeeUnitRatio {
return SettlementResult{}, ErrInvalidServiceUnit
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
serviceFee, err := serviceFeeCent(rule, netPaid)
if err != nil {
return SettlementResult{}, err
}
splitBase := netPaid - paymentFee - serviceFee
if splitBase < 0 {
splitBase = 0
}
result := SettlementResult{
GrossPaidAmountCent: input.GrossPaidAmountCent,
RefundAmountCent: input.RefundAmountCent,
NetPaidAmountCent: netPaid,
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: input.MarkupAmountCent,
SplitBaseAmountCent: splitBase,
}
if rule.TaxEnabled {
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
for _, party := range input.GrossProfitByParty {
profit := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(max(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
result.TaxAmountCent += tax
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
}
}
return result, nil
}
func serviceFeeCent(rule Rule, netPaid int64) (int64, error) {
switch rule.ServiceFeeUnit {
case ServiceFeeUnitPercent:
return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil
case ServiceFeeUnitFixedAmount:
return roundHalfUp(rule.ServiceFeeValue * 100), nil
case ServiceFeeUnitRatio:
if rule.ServiceFeeValue <= 0 {
return 0, nil
}
return roundHalfUp(float64(netPaid) / rule.ServiceFeeValue), nil
default:
return 0, ErrInvalidServiceUnit
}
}
func roundHalfUp(value float64) int64 {
return int64(math.Floor(value + 0.5))
}
func max(a, b int64) int64 {
if a > b {
return a
}
return b
}
@@ -0,0 +1,55 @@
package distribution
import "testing"
func TestDefaultRules(t *testing.T) {
cases := []struct {
mode string
check func(Rule) bool
}{
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 3.5 && r.PlatformSellRatio == 4 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 3.5 && r.DistributorSellRatio == 4 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 3.5 && r.DistributorBRatio == 4 }},
}
for _, item := range cases {
rule, err := DefaultRule(item.mode)
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 {
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
}
}
}
func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) {
rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300})
if err != nil {
t.Fatal(err)
}
if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 {
t.Fatalf("unexpected settlement: %#v", got)
}
}
func TestCalculateSettlementTaxesEachPositivePartyProfit(t *testing.T) {
rule := Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, TaxEnabled: true, TaxRateBPS: 100}
got, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000,
GrossProfitByParty: []PartyProfitInput{
{PartyRole: "distributor_a", IncomeAmountCent: 1000, CostAmountCent: 400},
{PartyRole: "distributor_b", IncomeAmountCent: 500, CostAmountCent: 700},
},
})
if err != nil {
t.Fatal(err)
}
if got.TaxAmountCent != 6 || len(got.TaxByParty) != 2 || got.TaxByParty[0].TaxAmountCent != 6 || got.TaxByParty[1].TaxAmountCent != 0 {
t.Fatalf("unexpected tax result: %#v", got)
}
}
func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
_, err := CalculateSettlement(Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount}, SettlementInput{GrossPaidAmountCent: 1, RefundAmountCent: 2})
if err != ErrInvalidAmount {
t.Fatalf("err = %v", err)
}
}
@@ -0,0 +1,399 @@
package distribution
import (
"context"
"errors"
"strings"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
var (
ErrSiteNotFound = errors.New("distribution site not found")
ErrRuleNotFound = errors.New("distribution rule not found")
ErrOrderNotFound = errors.New("distribution order not found")
ErrInvalidOrderKind = errors.New("invalid distribution order kind")
ErrAccountDisplayNotFound = errors.New("distribution account display not found")
ErrPaymentChannelNotFound = errors.New("distribution payment channel not found")
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
)
type Repository struct{ db *gorm.DB }
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
var rows []model.DistributionSite
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]SiteDTO, 0, len(rows))
for _, row := range rows {
items = append(items, siteDTO(row))
}
return items, nil
}
func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
row := model.DistributionSite{
SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain),
Status: "active", DisplayAccountEnabled: true, TaxRateBPS: 100,
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if req.SiteName != nil {
row.SiteName = strings.TrimSpace(*req.SiteName)
}
if req.Domain != nil {
row.Domain = strings.TrimSpace(*req.Domain)
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
if siteID == nil {
db = db.Where("site_id IS NULL")
} else {
db = db.Where("site_id = ?", *siteID)
}
if mode = strings.TrimSpace(mode); mode != "" {
db = db.Where("mode = ?", mode)
}
var rows []model.DistributionRuleVersion
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
items := make([]RuleDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ruleDTO(row))
}
return items, nil
}
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
version := 1
var maxVersion int
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
if req.SiteID == nil {
db = db.Where("site_id IS NULL")
} else {
db = db.Where("site_id = ?", *req.SiteID)
}
if err := db.Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return nil, err
}
version = maxVersion + 1
effectiveFrom := req.EffectiveFrom
if effectiveFrom.IsZero() {
effectiveFrom = time.Now()
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio,
DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio,
DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio,
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
}
if row.PaymentFeeBPS == 0 {
row.PaymentFeeBPS = 50
}
if row.TaxRateBPS == 0 {
row.TaxRateBPS = 100
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
item := ruleDTO(row)
return &item, nil
}
func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
var row model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
row.Status = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := ruleDTO(row)
return &item, nil
}
func siteDTO(row model.DistributionSite) SiteDTO {
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
}
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
page, size := query.Page, query.Size
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
base := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id")
if query.SiteID > 0 {
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
}
if query.Status != "" {
base = base.Where("ro.status = ?", query.Status)
}
switch strings.TrimSpace(query.Kind) {
case "sold":
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
case "receive":
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
case "", "all":
default:
return nil, ErrInvalidOrderKind
}
var total int64
if err := base.Session(&gorm.Session{}).Select("ro.id").Count(&total).Error; err != nil {
return nil, err
}
items := make([]DistributionOrderDTO, 0)
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at"
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
return nil, err
}
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
}
func (r *Repository) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
var item DistributionOrderDTO
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
if err != nil {
return nil, err
}
if item.OrderID == 0 {
return nil, ErrOrderNotFound
}
return &item, nil
}
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
var rows []model.DistributionSiteAccount
db := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC")
if status != "" {
db = db.Where("display_status = ?", status)
}
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
items := make([]AccountDisplayDTO, 0, len(rows))
for _, row := range rows {
items = append(items, AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus})
}
return items, nil
}
func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
var row model.DistributionSiteAccount
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountDisplayNotFound
}
return nil, err
}
row.DisplayStatus = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := &AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus}
return item, nil
}
func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
var rows []model.DistributionSitePaymentChannel
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]PaymentChannelDTO, 0, len(rows))
for _, row := range rows {
items = append(items, PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status})
}
return items, nil
}
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
return nil, err
}
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
}
func (r *Repository) UnbindPaymentChannel(ctx context.Context, id uint64) error {
result := r.db.WithContext(ctx).Delete(&model.DistributionSitePaymentChannel{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrPaymentChannelNotFound
}
return nil
}
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
var rows []model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]SettlementBatchDTO, 0, len(rows))
for _, row := range rows {
items = append(items, SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status})
}
return items, nil
}
func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
if req.PeriodEnd.Before(req.PeriodStart) {
return nil, ErrInvalidPeriod
}
row := model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSettlementBatchNotFound
}
return nil, err
}
if row.Status == "settled" {
return nil, ErrBatchAlreadySettled
}
now := time.Now()
row.Status = "settled"
row.SettledAt = &now
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
var rule model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
if req.SiteID == 0 || orderID == 0 {
return nil, ErrInvalidAmount
}
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
for _, party := range req.Parties {
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
if err != nil {
return nil, err
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, SettlementStatus: "pending"}
if err := tx.Where("order_id = ?", orderID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
if err := tx.Where("order_id = ?", orderID).Delete(&model.DistributionOrderParty{}).Error; err != nil {
return err
}
for idx, party := range req.Parties {
tax := int64(0)
if idx < len(result.TaxByParty) {
tax = result.TaxByParty[idx].TaxAmountCent
}
profit := party.IncomeAmountCent - party.CostAmountCent
row := model.DistributionOrderParty{OrderID: orderID, SiteID: req.SiteID, PartyRole: party.PartyRole, PartyType: party.PartyType, PartyID: party.PartyID, Ratio: party.Ratio, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent, ProfitAmountCent: profit, TaxAmountCent: tax}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error
})
if err != nil {
return nil, err
}
return r.FindOrder(ctx, orderID)
}
@@ -0,0 +1,87 @@
package distribution
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func distributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
t.Fatal(err)
}
return db
}
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
_, err := repo.CreateFinancialSnapshot(t.Context(), order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
if err != nil {
t.Fatal(err)
}
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
if err != nil {
t.Fatal(err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
t.Fatalf("unexpected page: %#v", page)
}
if _, err := repo.FindOrder(t.Context(), order.ID); err != nil {
t.Fatal(err)
}
}
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 9, Priority: 1})
if err != nil {
t.Fatal(err)
}
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
t.Fatalf("channels = %#v, err=%v", items, err)
}
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); err != nil {
t.Fatal(err)
}
start := time.Now().Add(-time.Hour)
batch, err := repo.CreateSettlementBatch(t.Context(), 1, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now()})
if err != nil {
t.Fatal(err)
}
settled, err := repo.MarkSettlementBatch(t.Context(), batch.ID)
if err != nil || settled.Status != "settled" {
t.Fatalf("settled = %#v, err=%v", settled, err)
}
if _, err := repo.MarkSettlementBatch(t.Context(), batch.ID); err != ErrBatchAlreadySettled {
t.Fatalf("second settle err = %v", err)
}
}
@@ -0,0 +1,129 @@
package distribution
import (
"context"
"errors"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct{ repo *Repository }
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func (s *Service) available() error {
if s == nil || s.repo == nil {
return ErrDependencyUnavailable
}
return nil
}
func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSites(ctx)
}
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindSite(ctx, id)
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSite(ctx, req)
}
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSite(ctx, id, req)
}
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListRules(ctx, siteID, mode)
}
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateRule(ctx, adminID, req)
}
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateRuleStatus(ctx, id, status)
}
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListOrders(ctx, query)
}
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindOrder(ctx, id)
}
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListAccountDisplays(ctx, siteID, status)
}
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountDisplay(ctx, id, status)
}
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListPaymentChannels(ctx, siteID)
}
func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.BindPaymentChannel(ctx, siteID, req)
}
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UnbindPaymentChannel(ctx, id)
}
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSettlementBatches(ctx, siteID)
}
func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSettlementBatch(ctx, adminID, req)
}
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.MarkSettlementBatch(ctx, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
}