CheckoutService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\service;
  4. use app\miniapi\model\MiniCheckout;
  5. use app\miniapi\model\MiniBalanceLedger;
  6. use app\miniapi\http\Fields;
  7. use app\miniapi\http\Input;
  8. use app\miniapi\exception\ApiException;
  9. use plugin\saiadmin\app\model\water\WaterEnterprise;
  10. use plugin\saiadmin\app\service\WaterPurchaseReceipt;
  11. use plugin\saiadmin\app\model\water\WaterSku;
  12. /** Checkout intents become water receipts and rights only after a successful balance payment. */
  13. final class CheckoutService
  14. {
  15. public function __construct(private readonly Access $access)
  16. {
  17. }
  18. public function quote(int $enterprise, array $input): array
  19. {
  20. return $this->buildQuote($enterprise, $input, false);
  21. }
  22. private function buildQuote(int $enterprise, array $input, bool $lock): array
  23. {
  24. $this->access->enterprise($enterprise, true);
  25. Fields::only($input, ['items']);
  26. $items = $input['items'] ?? null;
  27. if (!is_array($items) || !array_is_list($items) || count($items) < 1 || count($items) > 20) {
  28. throw new ApiException(42201, '请选择1至20种套餐', 422);
  29. }
  30. $result = [];
  31. $seen = [];
  32. $total = 0;
  33. $parsed = [];
  34. foreach ($items as $item) {
  35. if (!is_array($item)) {
  36. throw new ApiException(42201, '套餐明细格式不正确', 422);
  37. }
  38. Fields::only($item, ['package_id', 'quantity']);
  39. $id = Fields::id($item, 'package_id');
  40. $quantity = Input::integer($item['quantity'] ?? null, 'quantity', 1, 100);
  41. if (isset($seen[$id])) {
  42. throw new ApiException(42201, '同一套餐请合并数量', 422);
  43. }
  44. $seen[$id] = true;
  45. $parsed[] = ['package_id' => $id, 'quantity' => $quantity];
  46. }
  47. $catalog = new Catalog();
  48. $locked = $lock ? $catalog->lockedDetails(array_keys($seen)) : [];
  49. foreach ($parsed as $item) {
  50. $id = $item['package_id'];
  51. $quantity = $item['quantity'];
  52. $package = $lock ? $locked[$id] : $catalog->detail($id);
  53. $amount = $package['price_cents'] * $quantity;
  54. $total += $amount;
  55. $result[] = [
  56. 'package_id' => $id,
  57. 'name' => $package['name'],
  58. 'sku_id' => $package['sku_id'],
  59. 'quantity' => $quantity,
  60. 'buckets' => $package['buckets'] * $quantity,
  61. 'unit_price_cents' => $package['price_cents'],
  62. 'amount_cents' => $amount,
  63. ];
  64. }
  65. usort($result, fn ($a, $b) => $a['package_id'] <=> $b['package_id']);
  66. return [
  67. 'items' => $result,
  68. 'quote_revision' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR)),
  69. 'subtotal_cents' => $total,
  70. 'discount_cents' => 0,
  71. 'amount_cents' => $total,
  72. 'payment_methods' => ['balance', 'wechat'],
  73. 'capabilities' => [
  74. 'payment' => false,
  75. 'balance' => true,
  76. 'first_purchase_discount' => false,
  77. 'invoice_issuance' => false,
  78. ],
  79. 'balance_cents' => BalanceService::cents($enterprise),
  80. 'agreement_url' => config('mini_api.purchase_agreement_url', '') ?: null,
  81. ];
  82. }
  83. public function create(int $enterprise, array $input): array
  84. {
  85. $this->access->enterprise($enterprise, true);
  86. Fields::only($input, [
  87. 'items',
  88. 'expected_amount_cents',
  89. 'quote_revision',
  90. 'agreement_accepted',
  91. 'invoice',
  92. 'request_key',
  93. ]);
  94. if (($input['agreement_accepted'] ?? false) !== true) {
  95. throw new ApiException(42201, '请确认购买与配送服务协议', 422);
  96. }
  97. $expected = Input::integer($input['expected_amount_cents'] ?? null, 'expected_amount_cents', 0, PHP_INT_MAX);
  98. $invoice = $input['invoice'] ?? null;
  99. if ($invoice !== null) {
  100. if (!is_array($invoice)) {
  101. throw new ApiException(42201, '发票资料格式不正确', 422);
  102. }
  103. Fields::only($invoice, ['title', 'tax_number', 'email']);
  104. $invoice = [
  105. 'title' => Fields::text($invoice, 'title', 120),
  106. 'tax_number' => Fields::text($invoice, 'tax_number', 30),
  107. 'email' => Fields::text($invoice, 'email', 120),
  108. ];
  109. if (!filter_var($invoice['email'], FILTER_VALIDATE_EMAIL)) {
  110. throw new ApiException(42201, '发票邮箱格式不正确', 422);
  111. }
  112. }
  113. $key = Fields::text($input, 'request_key', 80);
  114. $revision = isset($input['quote_revision']) ? Fields::text($input, 'quote_revision', 64) : null;
  115. return Commands::run($this->access->actor, $key, 'checkout:' . $enterprise, $input, function () use ($enterprise, $input, $expected, $invoice, $revision) {
  116. $enterpriseRow = $this->access->enterprise($enterprise, true);
  117. $quote = $this->buildQuote($enterprise, ['items' => $input['items'] ?? null], true);
  118. if ($quote['amount_cents'] !== $expected || ($revision !== null && !hash_equals($quote['quote_revision'], $revision))) {
  119. throw new ApiException(40904, '套餐内容或价格已变化,请重新确认', 409);
  120. }
  121. $checkout = MiniCheckout::query()->create([
  122. 'user_id' => $this->access->actor->userId,
  123. 'enterprise_id' => $enterprise,
  124. 'code' => 'MP' . date('YmdHis') . bin2hex(random_bytes(6)),
  125. 'status' => 'pending',
  126. 'items' => $quote['items'],
  127. 'address_snapshot' => [
  128. 'name' => $enterpriseRow->name,
  129. 'address' => $enterpriseRow->address,
  130. 'region' => $enterpriseRow->region,
  131. 'contact' => $enterpriseRow->contact,
  132. 'phone' => $enterpriseRow->phone,
  133. ],
  134. 'invoice' => $invoice,
  135. 'amount_cents' => $quote['amount_cents'],
  136. 'created_at' => date('Y-m-d H:i:s'),
  137. 'expires_at' => date('Y-m-d H:i:s', time() + 1800),
  138. ]);
  139. return self::present($checkout);
  140. }, fn () => $this->access->enterprise($enterprise, true));
  141. }
  142. public static function present($checkout): array
  143. {
  144. return [
  145. 'id' => (int) $checkout->id,
  146. 'code' => $checkout->code,
  147. 'enterprise_id' => (int) $checkout->enterprise_id,
  148. 'status' => $checkout->status === 'pending' && $checkout->expires_at <= date('Y-m-d H:i:s') ? 'expired' : $checkout->status,
  149. 'items' => $checkout->items,
  150. 'amount_cents' => (int) $checkout->amount_cents,
  151. 'created_at' => $checkout->created_at,
  152. 'expires_at' => $checkout->expires_at,
  153. 'payment_enabled' => true,
  154. 'payment_method' => $checkout->payment_method,
  155. 'paid_at' => $checkout->paid_at,
  156. 'balance_cents' => BalanceService::cents((int) $checkout->enterprise_id),
  157. ];
  158. }
  159. public function detail(int $enterprise, int $id): array
  160. {
  161. $this->access->enterprise($enterprise);
  162. $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->find($id);
  163. if (!$checkout) {
  164. throw new ApiException(40401, '订单不存在', 404);
  165. }
  166. $address = $checkout->address_snapshot;
  167. $address['phone'] = WaterPresenter::phone($address['phone']);
  168. return self::present($checkout) + ['address' => $address];
  169. }
  170. public function cancel(int $enterprise, int $id, array $input): array
  171. {
  172. $this->access->enterprise($enterprise, true);
  173. Fields::only($input, ['request_key']);
  174. return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'checkout.cancel:' . $enterprise . ':' . $id, $input, function () use ($enterprise, $id) {
  175. $this->access->enterprise($enterprise, true);
  176. $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->where('id', $id)->lockForUpdate()->first();
  177. if (!$checkout) {
  178. throw new ApiException(40401, '订单不存在', 404);
  179. }
  180. if (!in_array($checkout->status, ['pending', 'cancelled'], true)) {
  181. throw new ApiException(40902, '当前订单不能取消', 409);
  182. }
  183. $checkout->status = 'cancelled';
  184. $checkout->save();
  185. return self::present($checkout);
  186. }, fn () => $this->access->enterprise($enterprise, true));
  187. }
  188. public function pay(int $enterprise, int $id, array $input): array
  189. {
  190. $this->access->enterprise($enterprise, true);
  191. Fields::only($input, ['request_key', 'payment_method']);
  192. $key = Fields::text($input, 'request_key', 80);
  193. $method = Fields::choice($input, 'payment_method', ['balance', 'wechat']);
  194. if ($method === 'wechat') {
  195. throw new ApiException(50303, '微信支付接口已预留,请先使用企业余额', 503);
  196. }
  197. return Commands::run($this->access->actor, $key, 'checkout.pay:' . $enterprise . ':' . $id, $input, function () use ($enterprise, $id, $method, $key) {
  198. $checkout = MiniCheckout::query()->where('enterprise_id', $enterprise)->where('id', $id)->lockForUpdate()->first();
  199. if (!$checkout) {
  200. throw new ApiException(40401, '订单不存在', 404);
  201. }
  202. if ($checkout->status !== 'pending') {
  203. throw new ApiException(40902, '当前订单不能支付', 409);
  204. }
  205. if ($checkout->expires_at <= date('Y-m-d H:i:s')) {
  206. throw new ApiException(40906, '订单已超期,请重新创建订单', 409);
  207. }
  208. $items = $this->snapshotItems($checkout);
  209. // Receipt writers consistently lock SKU(s), then enterprise, then accounts/balance.
  210. $skuIds = array_unique(array_column($items, 'sku_id'));
  211. sort($skuIds, SORT_NUMERIC);
  212. foreach ($skuIds as $skuId) {
  213. $sku = WaterSku::query()->where('id', $skuId)->lockForUpdate()->first();
  214. if (!$sku || (int) $sku->status !== 1) {
  215. throw new ApiException(40902, '订单中的商品规格已停用或不存在,请联系平台', 409);
  216. }
  217. }
  218. $enterpriseRow = WaterEnterprise::query()->where('id', $enterprise)->lockForUpdate()->first();
  219. if (!$enterpriseRow || (int) $enterpriseRow->status !== 1) {
  220. throw new ApiException(40301, '企业已停用或不存在', 403);
  221. }
  222. $balance = BalanceService::locked($enterprise);
  223. $amount = (int) $checkout->amount_cents;
  224. $before = (int) $balance->balance_cents;
  225. if ($before < $amount) {
  226. throw new ApiException(40905, '企业余额不足', 409, [
  227. 'balance_cents' => $before,
  228. 'required_cents' => $amount,
  229. ]);
  230. }
  231. $receipts = new WaterPurchaseReceipt();
  232. foreach ($items as $item) {
  233. for ($index = 1; $index <= $item['quantity']; $index++) {
  234. $receipts->issue(
  235. $enterprise,
  236. [
  237. 'id' => $item['package_id'],
  238. 'name' => $item['name'],
  239. 'sku_id' => $item['sku_id'],
  240. 'buckets' => intdiv($item['buckets'], $item['quantity']),
  241. 'price_cents' => $item['unit_price_cents'],
  242. ],
  243. $checkout->code . '-' . $item['package_id'] . '-' . $index,
  244. 0,
  245. true,
  246. $id
  247. );
  248. }
  249. }
  250. $after = $before - $amount;
  251. $now = date('Y-m-d H:i:s');
  252. $balance->balance_cents = $after;
  253. $balance->updated_at = $now;
  254. $balance->save();
  255. MiniBalanceLedger::query()->insert([
  256. 'enterprise_id' => $enterprise,
  257. 'checkout_id' => $id,
  258. 'user_id' => $this->access->actor->userId,
  259. 'request_key' => $key,
  260. 'direction' => -1,
  261. 'amount_cents' => $amount,
  262. 'before_balance' => $before,
  263. 'after_balance' => $after,
  264. 'source' => 'checkout_balance',
  265. 'created_at' => $now,
  266. ]);
  267. $checkout->status = 'paid';
  268. $checkout->payment_method = $method;
  269. $checkout->paid_at = $now;
  270. $checkout->save();
  271. return self::present($checkout) + ['balance_cents' => $after];
  272. }, fn () => $this->access->enterprise($enterprise, true));
  273. }
  274. /** Check persisted server snapshots before any financial write. */
  275. private function snapshotItems(MiniCheckout $checkout): array
  276. {
  277. $items = $checkout->items;
  278. if (!is_array($items) || !array_is_list($items) || count($items) < 1 || count($items) > 20) {
  279. throw new ApiException(40902, '订单快照不完整,请联系平台', 409);
  280. }
  281. $total = 0;
  282. $seen = [];
  283. foreach ($items as $item) {
  284. if (!is_array($item)) {
  285. throw new ApiException(40902, '订单快照不完整,请联系平台', 409);
  286. }
  287. foreach (['package_id', 'sku_id', 'quantity', 'buckets', 'unit_price_cents', 'amount_cents'] as $field) {
  288. if (!isset($item[$field]) || !is_int($item[$field]) || $item[$field] <= 0) {
  289. throw new ApiException(40902, '订单快照数据无效,请联系平台', 409);
  290. }
  291. }
  292. if ($item['quantity'] > 100 || $item['buckets'] % $item['quantity'] !== 0
  293. || $item['amount_cents'] !== $item['quantity'] * $item['unit_price_cents']
  294. || !is_string($item['name'] ?? null) || trim($item['name']) === ''
  295. || isset($seen[$item['package_id']])) {
  296. throw new ApiException(40902, '订单快照数据无效,请联系平台', 409);
  297. }
  298. $seen[$item['package_id']] = true;
  299. $total += $item['amount_cents'];
  300. }
  301. if ($total !== (int) $checkout->amount_cents) {
  302. throw new ApiException(40902, '订单快照与应付金额不一致,请联系平台', 409);
  303. }
  304. return $items;
  305. }
  306. }