validated(); // 受付番号の重複チェック $existing = PaymentTransaction::where('syuno_recv_num', $validated['SyunoRecvNum'])->first(); if ($existing) { // 期限切れ(DB未更新)を補正 if ($existing->status === '入金待ち' && $existing->pay_limit && $existing->pay_limit->lessThan(now())) { $existing->update(['status' => '支払期限切れ']); } if ($existing->status === '入金済み') { return response()->json([ 'error' => ['code' => 'E17', 'message' => '入金処理完了済みの受付番号です。'] ], 400); } if ($existing->status === '支払期限切れ') { return response()->json([ 'error' => ['code' => 'E23', 'message' => '支払期限切れの受付番号です。'] ], 400); } if ($existing->status === '取消済み') { return response()->json([ 'error' => ['code' => 'E24', 'message' => '取消済みの受付番号です。'] ], 400); } // 冪等対応:入金待ちの既存データがある場合は同一リンクを返す if (!empty($existing->kessai_no) && $existing->status === '入金待ち') { $creditUrl = $linkBuilder->buildCreditUrl($existing->kessai_no); return response()->json([ 'result' => 'OK', 'creditPaymentUrl' => $creditUrl, 'SyunoRecvNum' => $existing->syuno_recv_num, ]); } return response()->json([ 'error' => ['code' => 'INVALID_REQUEST', 'message' => '既に登録済みの受付番号です。'] ], 400); } // SyunoFreeArray構築 $freeArray = $this->buildFreeArray($validated); // SOAP送信データ構築 $inData = [ 'SyunoRecvNum' => $validated['SyunoRecvNum'], 'SyunoTel' => $validated['SyunoTel'], 'SyunoNameKanji' => $validated['SyunoNameKanji'], 'SyunoPayLimit' => $validated['SyunoPayLimit'], 'SyunoPayAmount' => (string) $validated['SyunoPayAmount'], 'SyunoFreeArray' => $freeArray, ]; if (!empty($validated['SyunoNameKana'])) { $inData['SyunoNameKana'] = $validated['SyunoNameKana']; } if (!empty($validated['SyunoReserveNum'])) { $inData['SyunoReserveNum'] = $validated['SyunoReserveNum']; } try { // Wellnet受付登録 $result = $soapService->register($inData); } catch (WellnetSoapException $e) { $code = $e->getWellnetCode(); $errorCode = $this->normalizeWellnetErrorCode($code); return response()->json([ 'error' => [ 'code' => $errorCode, 'message' => 'Wellnet受付登録に失敗しました: ' . $e->getMessage(), ] ], 400); } catch (RuntimeException $e) { return response()->json([ 'error' => [ 'code' => 'E99', 'message' => 'Wellnet通信エラー: ' . $e->getMessage(), ] ], 500); } $kessaiNo = $result['KKessaiNo']; // 決済リンク生成 $creditUrl = $linkBuilder->buildCreditUrl($kessaiNo); // トランザクション保存 PaymentTransaction::create([ 'syuno_recv_num' => $validated['SyunoRecvNum'], 'payment_type' => 'credit', 'status' => '入金待ち', 'amount' => $validated['SyunoPayAmount'], 'pay_limit' => $this->parsePayLimit($validated['SyunoPayLimit']), 'kessai_no' => $kessaiNo, 'name_kanji' => $validated['SyunoNameKanji'], 'name_kana' => $validated['SyunoNameKana'] ?? null, 'tel' => $validated['SyunoTel'], 'subscription_flg' => $validated['subscription_flg'], 'free_area' => $this->buildFreeAreaJson($validated), 'wellnet_response' => json_encode($result, JSON_UNESCAPED_UNICODE), ]); return response()->json([ 'result' => 'OK', 'creditPaymentUrl' => $creditUrl, 'SyunoRecvNum' => $validated['SyunoRecvNum'], ]); } /** * 継続課金請求(API 1.2) * * POST /api/newwipe/credit/subscription/charge */ public function chargeSubscription( SubscriptionChargeRequest $request, WellnetCreditService $creditService ): JsonResponse { $validated = $request->validated(); // 継続課金会員情報取得 $subscription = CreditSubscription::where('credit_member_id', $validated['member_id']) ->where('subscription_status', 1) ->first(); if (!$subscription) { return response()->json([ 'error' => [ 'code' => 'E21', 'message' => '継続課金が未登録です。', ] ], 400); } $cardSeq = $validated['card_seq'] ?? $subscription->credit_card_seq; // execute_date(ログ用途) $executeDate = $validated['execute_date']; $executeDateParsed = \Carbon\Carbon::createFromFormat('Ymd', $executeDate, 'Asia/Tokyo')->startOfDay(); $today = \Carbon\Carbon::now('Asia/Tokyo')->startOfDay(); // execute_dateをDB保存用に変換(YYYYMMDD → YYYY-MM-DD) $executeDateFormatted = $executeDateParsed->format('Y-m-d'); // api.pdf 上は execute_date の制約が明記されていないため、日付の実在性のみをバリデーションで担保し、 // ここでは未来日/過去日の制限は行わずそのまま処理する。 // 請求履歴レコード作成(processing) $log = CreditPaymentLog::create([ 'credit_subscription_id' => $subscription->credit_subscription_id, 'contract_id' => $validated['contract_id'], 'user_id' => $subscription->user_id, 'request_amount' => $validated['amount'], 'execute_date' => $executeDateFormatted, 'request_at' => now(), 'status' => 'pending', ]); try { // 与信照会 $authResult = $creditService->authorize( $subscription->credit_member_id, $validated['amount'], $cardSeq ); $paymentNumber = $authResult['paymentNumber'] ?? null; // 売上確定 $creditService->capture($paymentNumber); // 成功時の履歴更新 $log->update([ 'response_result' => 'OK', 'payment_number' => $paymentNumber, 'status' => 'success', ]); // payment_transactionにも記録(返金用にpayment_numberを保持) // syuno_recv_numは20文字以内の英数字で生成(契約IDの文字種に依存しない) $syunoRecvNum = 'S' . substr(hash('sha256', $validated['contract_id'] . '|' . $executeDate), 0, 19); PaymentTransaction::create([ 'syuno_recv_num' => $syunoRecvNum, 'payment_type' => 'credit', 'status' => '入金済み', 'amount' => $validated['amount'], 'payment_number' => $paymentNumber, 'subscription_flg' => 1, 'paid_datetime' => now(), ]); return response()->json([ 'result' => 'OK', 'contract_id' => $validated['contract_id'], 'member_id' => $validated['member_id'], ]); } catch (RuntimeException $e) { // エラー時の履歴更新 $log->update([ 'response_result' => 'NG', 'response_message' => $e->getMessage(), 'status' => 'error', ]); $mapped = $this->mapCreditException($e); return response()->json([ 'error' => [ 'code' => $mapped['code'], 'message' => $mapped['message'], ] ], $mapped['http']); } } /** * SyunoFreeArray構築(Index付き配列) */ private function buildFreeArray(array $validated): array { $freeArray = []; for ($i = 1; $i <= 23; $i++) { $key = 'SyunoFree' . $i; if (isset($validated[$key]) && $validated[$key] !== '') { $freeArray[$i] = $validated[$key]; } } return $freeArray; } /** * Free AreaをJSON保存用に構築 */ private function buildFreeAreaJson(array $validated): ?array { $freeArea = []; for ($i = 1; $i <= 23; $i++) { $key = 'SyunoFree' . $i; if (isset($validated[$key]) && $validated[$key] !== '') { $freeArea[$key] = $validated[$key]; } } return !empty($freeArea) ? $freeArea : null; } /** * 支払期限文字列(YYYYMMDDhhmm)をdatetime形式に変換 */ private function parsePayLimit(string $payLimit): string { // YYYYMMDDhhmm → YYYY-MM-DD hh:mm:00 return substr($payLimit, 0, 4) . '-' . substr($payLimit, 4, 2) . '-' . substr($payLimit, 6, 2) . ' ' . substr($payLimit, 8, 2) . ':' . substr($payLimit, 10, 2) . ':00'; } /** * WellnetエラーコードをAPIエラーコード形式へ正規化 */ private function normalizeWellnetErrorCode(string $code): string { if (!ctype_digit($code)) { return $code; } $trimmed = ltrim($code, '0'); return 'R' . ($trimmed === '' ? '0' : $trimmed); } /** * クレジットAPIの例外をAPIエラーへマップ */ private function mapCreditException(RuntimeException $e): array { $status = (int) $e->getCode(); $message = $e->getMessage(); if ($status === 401 || $status === 403) { return ['code' => 'E01', 'message' => '利用不可: ' . $message, 'http' => 400]; } if ($status === 404) { return ['code' => 'E02', 'message' => 'データエラー: ' . $message, 'http' => 400]; } if ($status === 400) { return ['code' => 'E09', 'message' => 'データエラー: ' . $message, 'http' => 400]; } if (str_contains($message, 'INVALID_OPERATION')) { return ['code' => 'E13', 'message' => 'データエラー: ' . $message, 'http' => 400]; } return ['code' => 'E99', 'message' => '継続課金処理に失敗しました: ' . $message, 'http' => 500]; } }