ShareCommissionRecorder.php 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. <?php
  2. namespace App\Services;
  3. use App\Models\BalanceLog;
  4. use App\Models\ShareCommissionRecord;
  5. use App\Models\User;
  6. use Brick\Math\BigDecimal;
  7. use Brick\Math\RoundingMode;
  8. use Carbon\Carbon;
  9. use Illuminate\Database\Eloquent\Builder;
  10. use Illuminate\Support\Facades\DB;
  11. /** Registers rebates that have already been paid; never credits a wallet. */
  12. class ShareCommissionRecorder
  13. {
  14. public const SOURCE_TYPE = 'recharge_rebate_log';
  15. public function preview(BalanceLog $rebate): array
  16. {
  17. $result = $this->prepare($rebate);
  18. if ($result['status'] === 'skipped') return $result;
  19. $result['status'] = $this->recordQuery($result['attributes'])->exists() ? 'existing' : 'pending';
  20. return $result;
  21. }
  22. /** Context supplies the original base and percentage during a new rebate payout. */
  23. public function record(BalanceLog $rebate, array $context = []): array
  24. {
  25. return DB::transaction(function () use ($rebate, $context) {
  26. $locked = BalanceLog::where('id', $rebate->id)->lockForUpdate()->first();
  27. if (!$locked) return ['status' => 'skipped', 'reason' => 'missing_log'];
  28. $result = $this->prepare($locked, $context);
  29. if ($result['status'] === 'skipped') return $result;
  30. // Current read also protects callers whose transaction snapshot predates a backfill.
  31. if ($this->recordQuery($result['attributes'])->lockForUpdate()->first()) {
  32. $result['status'] = 'existing';
  33. return $result;
  34. }
  35. ShareCommissionRecord::create($result['attributes']);
  36. $result['status'] = 'created';
  37. return $result;
  38. });
  39. }
  40. private function recordQuery(array $attributes): Builder
  41. {
  42. return ShareCommissionRecord::where('source_type', self::SOURCE_TYPE)
  43. ->where('source_id', $attributes['source_id'])
  44. ->where('type', 'commission');
  45. }
  46. private function prepare(BalanceLog $rebate, array $context = []): array
  47. {
  48. if (!$rebate->exists || $rebate->change_type !== '充值返现' || (int)$rebate->type !== 1) {
  49. return ['status' => 'skipped', 'reason' => 'not_recharge_rebate'];
  50. }
  51. $amount = $this->decimal($rebate->amount);
  52. $before = $this->decimal($rebate->before_balance, 10);
  53. $after = $this->decimal($rebate->after_balance, 10);
  54. if ($amount === null || bccomp($amount, '0', 4) <= 0) {
  55. return ['status' => 'skipped', 'reason' => 'invalid_amount'];
  56. }
  57. if ($before === null || $after === null || $this->decimal(bcsub($after, $before, 10)) !== $amount) {
  58. return ['status' => 'skipped', 'reason' => 'unbalanced_log'];
  59. }
  60. $promoters = User::where('member_id', $rebate->member_id)->limit(2)->get(['id']);
  61. if ($promoters->count() !== 1) {
  62. return ['status' => 'skipped', 'reason' => $promoters->isEmpty() ? 'missing_promoter' : 'ambiguous_promoter'];
  63. }
  64. $rawDate = $rebate->getRawOriginal('created_at');
  65. if (!$rawDate) return ['status' => 'skipped', 'reason' => 'missing_date'];
  66. try {
  67. $zone = config('app.timezone', 'Asia/Shanghai');
  68. $date = Carbon::parse($rawDate, $zone)->setTimezone($zone)->toDateString();
  69. } catch (\Throwable $e) {
  70. return ['status' => 'skipped', 'reason' => 'invalid_date'];
  71. }
  72. $inviteeId = null;
  73. $base = '0.0000';
  74. $rate = '0.0000';
  75. if ($context) {
  76. $base = $this->decimal($context['base_amount'] ?? null);
  77. $rate = $this->decimal($context['rate'] ?? null);
  78. $inviteeId = $context['invitee_user_id'] ?? null;
  79. if ($base === null || bccomp($base, '0', 4) <= 0 || $rate === null
  80. || bccomp($rate, '0', 4) < 0 || bccomp($rate, '10000', 4) >= 0 || !$inviteeId) {
  81. return ['status' => 'skipped', 'reason' => 'invalid_context'];
  82. }
  83. } elseif (preg_match('/^被邀请人:([0-9]{1,64})充值金额为:(\d{1,16}(?:\.\d{1,10})?)$/u', (string)$rebate->remark, $match)) {
  84. // Use the historical receipt, not today's rate or the invitee's current referral link.
  85. $base = $this->decimal($match[2]) ?? '0.0000';
  86. $invitees = User::where('member_id', $match[1])->limit(2)->get(['id']);
  87. if ($invitees->count() === 1) $inviteeId = $invitees->first()->id;
  88. if (bccomp($base, '0', 4) > 0) {
  89. $effectiveRate = $this->decimal(bcdiv(bcmul($amount, '100', 10), $base, 10));
  90. if ($effectiveRate !== null && bccomp($effectiveRate, '10000', 4) < 0) $rate = $effectiveRate;
  91. }
  92. }
  93. return [
  94. 'status' => 'pending',
  95. 'incomplete_metadata' => $inviteeId === null || bccomp($base, '0', 4) <= 0,
  96. 'attributes' => [
  97. 'stat_date' => $date,
  98. 'promoter_user_id' => $promoters->first()->id,
  99. 'invitee_user_id' => $inviteeId,
  100. 'type' => 'commission',
  101. 'category' => 'recharge',
  102. 'base_amount' => $base,
  103. 'rate' => $rate,
  104. 'amount' => $amount,
  105. 'source_type' => self::SOURCE_TYPE,
  106. 'source_id' => (string)$rebate->id,
  107. 'status' => 1,
  108. ],
  109. ];
  110. }
  111. private function decimal($value, int $scale = 4): ?string
  112. {
  113. if (is_float($value) && is_finite($value)) $value = sprintf('%.10F', $value);
  114. if (!is_scalar($value) || !preg_match('/^-?\d{1,16}(?:\.\d+)?$/', (string)$value)) return null;
  115. return (string)BigDecimal::of((string)$value)->toScale($scale, RoundingMode::HALF_UP);
  116. }
  117. }