// Package incentive implements spin's incentive-point engine. // // Money model (per project, from its Contract): // - BE = break-even floor amount → its 3-stage splits feed the WORKER // incentive-point pool, distributed by each member's portion. // - non-BE = (total − BE) → split between the COMPANY and the PARTNERS by a // configured ratio; the partner share becomes partner incentive // points (distributed among partners by portion). // // Each scope is broken into 계약금/중도금/잔금 (deposit/middle/final) by the // IncentiveConfig percentages. Points = KRW ÷ pointRate (환율). Quarterly // settlement pays out points accumulated beyond a member's rank quota. package incentive import "math" // Config mirrors the persisted IncentiveConfig (decoupled for pure compute). type Config struct { PointRate float64 DepositPct float64 MiddlePct float64 FinalPct float64 NonBECompanyPct float64 NonBEPartnerPct float64 RankQuota map[string]float64 } // MemberPortion is one project worker's contribution share. type MemberPortion struct { Email string Portion float64 // 0–100 IsPartner bool } // Stage is a computed (kind, scope) money bucket for a project. type Stage struct { Kind string `json:"kind"` Scope string `json:"scope"` Amount float64 `json:"amount"` Pct float64 `json:"pct"` } // UserAlloc is one member's computed allocation for a stage. type UserAlloc struct { Email string `json:"email"` Kind string `json:"kind"` Scope string `json:"scope"` Portion float64 `json:"portion"` Amount float64 `json:"amount"` Points float64 `json:"points"` } const ( KindDeposit = "deposit" KindMiddle = "middle" KindFinal = "final" ScopeBE = "be" ScopeNonBE = "non_be" ) // SplitScopes returns the BE and non-BE totals for a contract. func SplitScopes(total, be float64) (beAmount, nonBE float64) { if be > total { be = total } if be < 0 { be = 0 } return be, math.Max(0, total-be) } // ComputeStages produces the six (kind×scope) buckets for a project. func ComputeStages(total, be float64, cfg Config) []Stage { beAmt, nonBE := SplitScopes(total, be) mk := func(scope string, base float64) []Stage { return []Stage{ {Kind: KindDeposit, Scope: scope, Pct: cfg.DepositPct, Amount: base * cfg.DepositPct / 100}, {Kind: KindMiddle, Scope: scope, Pct: cfg.MiddlePct, Amount: base * cfg.MiddlePct / 100}, {Kind: KindFinal, Scope: scope, Pct: cfg.FinalPct, Amount: base * cfg.FinalPct / 100}, } } out := mk(ScopeBE, beAmt) out = append(out, mk(ScopeNonBE, nonBE)...) return out } // ComputeAllocs distributes each stage's money to members and converts to points. // - BE: every member gets stageAmount × portion%, then ÷ pointRate. // - non-BE: only the partner pool (NonBEPartnerPct of the stage) is distributed, // among partners weighted by their portion (the company share is company // income, not a member incentive). func ComputeAllocs(stages []Stage, members []MemberPortion, cfg Config) []UserAlloc { rate := cfg.PointRate if rate <= 0 { rate = 1 } partnerPortionSum := 0.0 for _, m := range members { if m.IsPartner { partnerPortionSum += m.Portion } } var out []UserAlloc for _, st := range stages { switch st.Scope { case ScopeBE: for _, m := range members { amt := st.Amount * m.Portion / 100 if amt == 0 { continue } out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope, Portion: m.Portion, Amount: amt, Points: amt / rate}) } case ScopeNonBE: pool := st.Amount * cfg.NonBEPartnerPct / 100 if pool == 0 || partnerPortionSum == 0 { continue } for _, m := range members { if !m.IsPartner { continue } share := pool * (m.Portion / partnerPortionSum) out = append(out, UserAlloc{Email: m.Email, Kind: st.Kind, Scope: st.Scope, Portion: m.Portion, Amount: share, Points: share / rate}) } } } return out } // Settlement is one member's quarterly calculation result. type Settlement struct { Email string `json:"email"` Rank string `json:"rank"` Quota float64 `json:"quota"` PointsCumul float64 `json:"pointsCumul"` ExcessPoints float64 `json:"excessPoints"` PaidPointsYTD float64 `json:"paidPointsYtd"` PayoutPoints float64 `json:"payoutPoints"` PayoutAmount float64 `json:"payoutAmount"` } // ComputeSettlement derives the incremental payout for one member: points beyond // the rank quota, minus what was already paid earlier in the year. func ComputeSettlement(email, rank string, pointsCumul, paidYTD float64, cfg Config) Settlement { quota := cfg.RankQuota[rank] excess := math.Max(0, pointsCumul-quota) payout := math.Max(0, excess-paidYTD) return Settlement{ Email: email, Rank: rank, Quota: quota, PointsCumul: pointsCumul, ExcessPoints: excess, PaidPointsYTD: paidYTD, PayoutPoints: payout, PayoutAmount: payout * cfg.PointRate, } }