diff --git a/app/Console/Commands/ExpirePaymentTransactions.php b/app/Console/Commands/ExpirePaymentTransactions.php new file mode 100644 index 0000000..42682f8 --- /dev/null +++ b/app/Console/Commands/ExpirePaymentTransactions.php @@ -0,0 +1,27 @@ +whereNotNull('pay_limit') + ->where('pay_limit', '<', now()) + ->update(['status' => '支払期限切れ']); + + $this->info("支払期限切れ更新: {$count}件"); + + return Command::SUCCESS; + } +} diff --git a/app/Exceptions/WellnetSoapException.php b/app/Exceptions/WellnetSoapException.php new file mode 100644 index 0000000..a6d962b --- /dev/null +++ b/app/Exceptions/WellnetSoapException.php @@ -0,0 +1,27 @@ +wellnetCode = $wellnetCode; + parent::__construct($message, 0, $previous); + } + + /** + * Wellnetエラーコード取得 + */ + public function getWellnetCode(): string + { + return $this->wellnetCode; + } +} diff --git a/app/Http/Controllers/Api/CreditPaymentController.php b/app/Http/Controllers/Api/CreditPaymentController.php new file mode 100644 index 0000000..4eb23cd --- /dev/null +++ b/app/Http/Controllers/Api/CreditPaymentController.php @@ -0,0 +1,327 @@ +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]; + } +} diff --git a/app/Http/Controllers/Api/PaymentCallbackController.php b/app/Http/Controllers/Api/PaymentCallbackController.php new file mode 100644 index 0000000..7653d2e --- /dev/null +++ b/app/Http/Controllers/Api/PaymentCallbackController.php @@ -0,0 +1,218 @@ + 'ret', + 'scd' => 'SyunoPayCode', + 'rno' => 'SyunoRecvNum', + 'kcd' => 'SyunoCorpCode', + 'sdt' => 'SyunoReserveDateTime', + 'ccd' => 'CvsCode', + 'tcd' => 'SyunoStoreCode', + 'nno' => 'MmsNumber', + 'ndt' => 'SyunoPayDatetime', + 'pri' => 'SyunoPayAmount', + 'ifg' => 'StampFlag', + 'pwd' => 'Md5Hash', + ]; + + /** + * 入金通知受信 + * + * POST /api/newwipe/callback + */ + public function receive(Request $request): Response + { + try { + // Wellnet疎通確認用のGET(パラメータなし)は常に200+000で応答 + if ($request->isMethod('get') && $request->all() === []) { + return response('000', 200)->header('Content-Type', 'text/plain'); + } + + // パラメータ解析(短縮名・論理名 両方対応) + $params = $this->parseParams($request); + + $retRaw = (string) ($params['ret'] ?? ''); + $scdRaw = (string) ($params['scd'] ?? ''); + $rnoRaw = (string) ($params['rno'] ?? ''); + $kcdRaw = (string) ($params['kcd'] ?? ''); + $pwdRaw = (string) ($params['pwd'] ?? ''); + $ndt = (string) ($params['ndt'] ?? ''); + $tcd = (string) ($params['tcd'] ?? ''); + $ccd = (string) ($params['ccd'] ?? ''); + $priRaw = (string) ($params['pri'] ?? ''); + + // 仕様上、各項目が空白埋めされる可能性があるためトリム値も用意 + $ret = trim($retRaw); + $scd = trim($scdRaw); + $rno = trim($rnoRaw); + $kcd = trim($kcdRaw); + $pwd = trim($pwdRaw); + $pri = trim($priRaw); + + if ($rno === '') { + return response('600', 200)->header('Content-Type', 'text/plain'); + } + + // MD5検証 + $secretKey = config('wellnet.callback.md5_secret'); + if (empty($secretKey)) { + if (app()->environment('production')) { + \Illuminate\Support\Facades\Log::critical('WELLNET_MD5_SECRET未設定のためcallback拒否'); + return response('800', 200)->header('Content-Type', 'text/plain'); + } + \Illuminate\Support\Facades\Log::warning('WELLNET_MD5_SECRET未設定: MD5検証スキップ'); + } else { + // 空白埋めの有無によりMD5算出対象が異なる可能性があるため、raw/trimの両方を許容 + $expectedHashRaw = md5("ret{$retRaw}scd{$scdRaw}rno{$rnoRaw}{$secretKey}"); + $expectedHashTrim = md5("ret{$ret}scd{$scd}rno{$rno}{$secretKey}"); + if ($pwd !== $expectedHashRaw && $pwd !== $expectedHashTrim) { + return response('800', 200)->header('Content-Type', 'text/plain'); + } + } + + // トランザクション検索 + $transaction = PaymentTransaction::where('syuno_recv_num', $rno)->first(); + if (!$transaction) { + return response('600', 200)->header('Content-Type', 'text/plain'); + } + + // pay_code / corp_code / 金額の整合性チェック(データ不備扱い) + $expectedPayCode = (string) config('wellnet.payment.pay_code', ''); + if ($expectedPayCode !== '' && $scd !== '' && $scd !== $expectedPayCode) { + return response('600', 200)->header('Content-Type', 'text/plain'); + } + $expectedCorpCode = (string) config('wellnet.payment.corp_code', ''); + if ($expectedCorpCode !== '' && $kcd !== '' && $kcd !== $expectedCorpCode) { + return response('600', 200)->header('Content-Type', 'text/plain'); + } + if ($pri !== '' && ctype_digit($pri)) { + if ((int) $pri !== (int) $transaction->amount) { + return response('600', 200)->header('Content-Type', 'text/plain'); + } + } + + // 冪等性チェック:既に入金済みの場合は正常応答 + if ($transaction->status === '入金済み') { + return response('000', 200)->header('Content-Type', 'text/plain'); + } + + // retが"000"(正常入金)でない場合はステータス更新しない + if ($ret !== '000') { + return response('000', 200)->header('Content-Type', 'text/plain'); + } + + // ステータス更新 + $updateData = [ + 'status' => '入金済み', + ]; + + if (!empty($ndt)) { + $updateData['paid_datetime'] = $this->parseDatetime($ndt); + } + if (!empty($tcd)) { + $updateData['store_code'] = $tcd; + } + if (!empty($ccd)) { + $updateData['cvs_code'] = $ccd; + } + + // クレジット決済の場合、CAFIS決済番号(paymentNumber)が送られてくる契約形態に備えて保存 + $paymentNumber = $request->input('paymentNumber') + ?? $request->input('payment_number') + ?? $request->input('paymentnumber') + ?? $request->input('pno') + ?? null; + if (!empty($paymentNumber) && empty($transaction->payment_number)) { + $updateData['payment_number'] = $paymentNumber; + } + + $transaction->update($updateData); + + return response('000', 200)->header('Content-Type', 'text/plain'); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::error('Wellnet callback処理エラー: ' . $e->getMessage(), [ + 'exception' => $e, + ]); + return response('100', 200)->header('Content-Type', 'text/plain'); + } + } + + /** + * リクエストパラメータ解析(短縮名・論理名 両方対応) + */ + private function parseParams(Request $request): array + { + $params = []; + + // 短縮名で取得 + $shortNames = ['ret', 'scd', 'rno', 'kcd', 'sdt', 'ccd', 'tcd', 'nno', 'ndt', 'pri', 'ifg', 'pwd']; + foreach ($shortNames as $name) { + $value = $request->input($name); + if ($value !== null) { + $params[$name] = $value; + } + } + + // 論理名でも取得(短縮名が無い場合のフォールバック) + $logicalMap = [ + 'SyunoRecvNum' => 'rno', + 'SyunoPayCode' => 'scd', + 'SyunoCorpCode' => 'kcd', + 'SyunoPayDatetime' => 'ndt', + 'SyunoPayAmount' => 'pri', + 'SyunoStoreCode' => 'tcd', + 'CvsCode' => 'ccd', + 'Md5Hash' => 'pwd', + ]; + + foreach ($logicalMap as $logical => $short) { + if (!isset($params[$short])) { + $value = $request->input($logical); + if ($value !== null) { + $params[$short] = $value; + } + } + } + + // retが未設定の場合のフォールバック + if (!isset($params['ret'])) { + $value = $request->input('result'); + if ($value !== null) { + $params['ret'] = $value; + } + } + + return $params; + } + + /** + * Wellnet日時文字列(YYYYMMDDHHMMSS)をdatetime形式に変換 + */ + private function parseDatetime(string $datetime): string + { + if (strlen($datetime) >= 14) { + return substr($datetime, 0, 4) . '-' + . substr($datetime, 4, 2) . '-' + . substr($datetime, 6, 2) . ' ' + . substr($datetime, 8, 2) . ':' + . substr($datetime, 10, 2) . ':' + . substr($datetime, 12, 2); + } + return $datetime; + } +} diff --git a/app/Http/Controllers/Api/PaymentStatusController.php b/app/Http/Controllers/Api/PaymentStatusController.php new file mode 100644 index 0000000..10f7260 --- /dev/null +++ b/app/Http/Controllers/Api/PaymentStatusController.php @@ -0,0 +1,82 @@ +query('SyunoRecvNum'); + + if (empty($syunoRecvNum)) { + return response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => 'SyunoRecvNumは必須です。', + ] + ], 400); + } + if (!preg_match('/^[a-zA-Z0-9]{1,20}$/', (string) $syunoRecvNum)) { + return response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => 'SyunoRecvNumの形式が不正です。', + ] + ], 400); + } + + $transaction = PaymentTransaction::where('syuno_recv_num', $syunoRecvNum)->first(); + + if (!$transaction) { + return response()->json([ + 'error' => [ + 'code' => 'DATA_NOT_FOUND', + 'message' => '指定された受付番号のデータが見つかりません。', + ] + ], 404); + } + + // レスポンス構築 + $response = [ + 'SyunoRecvNum' => $transaction->syuno_recv_num, + 'status' => $transaction->status, + 'amount' => $transaction->amount, + 'payLimit' => $transaction->pay_limit ? $transaction->pay_limit->toIso8601String() : null, + ]; + + // 入金済みの場合は追加情報 + if ($transaction->status === '入金済み') { + $response['paidDateTime'] = $transaction->paid_datetime + ? $transaction->paid_datetime->toIso8601String() + : null; + $response['storeCode'] = $transaction->store_code; + $response['storeName'] = $this->getStoreName($transaction->store_code); + } + + return response()->json($response); + } + + /** + * 店舗コードから店舗名を取得 + */ + private function getStoreName(?string $storeCode): ?string + { + if (empty($storeCode)) { + return null; + } + return config('wellnet.store_codes.' . $storeCode); + } +} diff --git a/app/Http/Controllers/Api/PaymentUpdateController.php b/app/Http/Controllers/Api/PaymentUpdateController.php new file mode 100644 index 0000000..a8b946e --- /dev/null +++ b/app/Http/Controllers/Api/PaymentUpdateController.php @@ -0,0 +1,290 @@ +validated(); + + // トランザクション検索 + $transaction = PaymentTransaction::where('syuno_recv_num', $validated['SyunoRecvNum'])->first(); + + if (!$transaction) { + return response()->json([ + 'error' => [ + // api.pdf の更新APIでは「未存在/見つからない」専用コードが無いため、実質的に削除済み扱いとする + 'code' => 'E24', + 'message' => '該当の支払データは既に削除済みです。', + ] + ], 400); + } + + // ステータスチェック + if ($transaction->status === '入金済み') { + return response()->json([ + 'error' => [ + 'code' => 'E17', + 'message' => '入金処理完了済みのため更新できません。', + ] + ], 400); + } + + if ($transaction->status === '取消済み') { + return response()->json([ + 'error' => [ + 'code' => 'E24', + 'message' => '既に取消済みです。', + ] + ], 400); + } + + // 期限切れ(DB未更新)を補正:更新は不可(取消は可) + if ($transaction->status === '入金待ち' && $transaction->pay_limit && $transaction->pay_limit->lessThan(now())) { + $transaction->update(['status' => '支払期限切れ']); + } + + // 取消処理 + $cancelFlag = $validated['cancelFlag'] ?? null; + if ($cancelFlag === true || $cancelFlag === 'true') { + return $this->handleCancel($transaction, $soapService); + } + + // 変更項目が一つも無い場合はエラー + $updatableKeys = [ + 'SyunoPayLimit', 'SyunoPayAmount', 'SyunoNameKanji', 'SyunoNameKana', 'SyunoTel', + 'SyunoFree1', 'SyunoFree2', 'SyunoFree3', 'SyunoFree4', 'SyunoFree5', 'SyunoFree6', + 'SyunoFree7', 'SyunoFree8', 'SyunoFree9', 'SyunoFree10', 'SyunoFree11', 'SyunoFree12', + 'SyunoFree13', 'SyunoFree14', 'SyunoFree15', 'SyunoFree16', 'SyunoFree17', 'SyunoFree18', + 'SyunoFree19', + 'SyunoFree20', 'SyunoFree21', + ]; + $hasUpdate = false; + foreach ($updatableKeys as $key) { + if (array_key_exists($key, $validated)) { + $hasUpdate = true; + break; + } + } + if (!$hasUpdate) { + return response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => '更新項目が指定されていません。', + ] + ], 400); + } + + // 期限切れチェック(更新の場合のみ、取消は可能) + if ($transaction->status === '支払期限切れ') { + return response()->json([ + 'error' => [ + 'code' => 'E23', + 'message' => '支払期限切れのため更新できません。', + ] + ], 400); + } + + // 更新処理 + return $this->handleUpdate($transaction, $validated, $soapService); + } + + /** + * 取消処理 + */ + private function handleCancel( + PaymentTransaction $transaction, + WellnetSoapService $soapService + ): JsonResponse { + try { + $soapService->cancel($transaction->syuno_recv_num); + } 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); + } + + $transaction->update(['status' => '取消済み']); + + return response()->json([ + 'updateStatus' => 'CANCELLED', + 'SyunoRecvNum' => $transaction->syuno_recv_num, + 'status' => '取消済み', + ]); + } + + /** + * 更新処理 + */ + private function handleUpdate( + PaymentTransaction $transaction, + array $validated, + WellnetSoapService $soapService + ): JsonResponse { + // 変更対象フィールドの抽出 + $inData = [ + 'SyunoRecvNum' => $validated['SyunoRecvNum'], + ]; + + // 基本フィールド + $updateFields = ['SyunoPayLimit', 'SyunoPayAmount', 'SyunoNameKanji', 'SyunoNameKana', 'SyunoTel']; + foreach ($updateFields as $field) { + if (isset($validated[$field])) { + $inData[$field] = $field === 'SyunoPayAmount' + ? (string) $validated[$field] + : $validated[$field]; + } + } + + // Free Area構築(未指定項目は'*'で送信) + $freeArray = []; + for ($i = 1; $i <= 21; $i++) { + $key = 'SyunoFree' . $i; + if (isset($validated[$key])) { + $freeArray[$i] = $validated[$key]; + } else { + $freeArray[$i] = '*'; + } + } + $inData['SyunoFreeArray'] = $freeArray; + + try { + $soapService->update($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); + } + + // ローカルDB更新 + $dbUpdate = []; + if (isset($validated['SyunoPayLimit'])) { + $dbUpdate['pay_limit'] = $this->parsePayLimit($validated['SyunoPayLimit']); + } + if (isset($validated['SyunoPayAmount'])) { + $dbUpdate['amount'] = $validated['SyunoPayAmount']; + } + if (isset($validated['SyunoNameKanji'])) { + $dbUpdate['name_kanji'] = $validated['SyunoNameKanji']; + } + if (isset($validated['SyunoNameKana'])) { + $dbUpdate['name_kana'] = $validated['SyunoNameKana']; + } + if (isset($validated['SyunoTel'])) { + $dbUpdate['tel'] = $validated['SyunoTel']; + } + + // Free Area JSONも更新 + $freeAreaJson = $transaction->free_area ?? []; + for ($i = 1; $i <= 21; $i++) { + $key = 'SyunoFree' . $i; + if (isset($validated[$key])) { + $freeAreaJson[$key] = $validated[$key]; + } + } + $dbUpdate['free_area'] = $freeAreaJson; + + if (!empty($dbUpdate)) { + $transaction->update($dbUpdate); + } + + // 最新の状態を取得 + $transaction->refresh(); + + $response = [ + 'updateStatus' => 'UPDATED', + 'SyunoRecvNum' => $transaction->syuno_recv_num, + 'amount' => $transaction->amount, + 'status' => $transaction->status, + ]; + + if (isset($validated['SyunoPayLimit'])) { + $response['newPayLimit'] = $this->parsePayLimitIso8601($validated['SyunoPayLimit']); + } + if (isset($validated['SyunoPayAmount'])) { + $response['newPayAmount'] = $validated['SyunoPayAmount']; + } + + return response()->json($response); + } + + /** + * 支払期限文字列(YYYYMMDDhhmm)をdatetime形式に変換(DB保存用) + */ + private function parsePayLimit(string $payLimit): string + { + return substr($payLimit, 0, 4) . '-' + . substr($payLimit, 4, 2) . '-' + . substr($payLimit, 6, 2) . ' ' + . substr($payLimit, 8, 2) . ':' + . substr($payLimit, 10, 2) . ':00'; + } + + /** + * 支払期限文字列(YYYYMMDDhhmm)をISO8601形式に変換(レスポンス用) + */ + private function parsePayLimitIso8601(string $payLimit): string + { + $datetime = substr($payLimit, 0, 4) . '-' + . substr($payLimit, 4, 2) . '-' + . substr($payLimit, 6, 2) . 'T' + . substr($payLimit, 8, 2) . ':' + . substr($payLimit, 10, 2) . ':00+09:00'; + return $datetime; + } + + /** + * WellnetエラーコードをAPIエラーコード形式へ正規化 + */ + private function normalizeWellnetErrorCode(string $code): string + { + if (!ctype_digit($code)) { + return $code; + } + $trimmed = ltrim($code, '0'); + return 'R' . ($trimmed === '' ? '0' : $trimmed); + } +} diff --git a/app/Http/Controllers/Api/RefundController.php b/app/Http/Controllers/Api/RefundController.php new file mode 100644 index 0000000..55ace90 --- /dev/null +++ b/app/Http/Controllers/Api/RefundController.php @@ -0,0 +1,186 @@ +validated(); + + // トランザクション検索 + $transaction = PaymentTransaction::where('syuno_recv_num', $validated['SyunoRecvNum'])->first(); + + if (!$transaction) { + return response()->json([ + 'error' => [ + 'code' => 'E17', + 'message' => '対象取引が未入金または存在しません。', + ] + ], 400); + } + + // 入金済みチェック + if ($transaction->status !== '入金済み') { + return response()->json([ + 'error' => [ + 'code' => 'E17', + 'message' => '未入金のため返金できません。', + ] + ], 400); + } + + // クレジット決済のみ返金可能 + if ($transaction->payment_type !== 'credit') { + return response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => 'コンビニ決済の返金はこのAPIでは対応していません。', + ] + ], 400); + } + + // 既に返金済みチェック + if ($transaction->refund_status === 'REFUNDED') { + return response()->json([ + 'error' => [ + 'code' => 'D01', + 'message' => '既に返金処理済みです。', + ] + ], 400); + } + + // 決済番号チェック(CAFIS決済番号が必要) + if (empty($transaction->payment_number)) { + return response()->json([ + 'error' => [ + 'code' => 'E20', + 'message' => 'クレジット決済番号(paymentNumber)が未取得のため返金できません。', + ] + ], 400); + } + + // 返金額計算 + $refundAmount = $validated['refundAmount'] ?? $transaction->amount; + $afterRefundPayAmount = $transaction->amount - $refundAmount; + + if ($afterRefundPayAmount < 0) { + return response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => '返金額が決済金額を超えています。', + ] + ], 400); + } + + $creditResponse = null; + try { + // 全額かつ与信取消可能な場合 + if ($afterRefundPayAmount === 0) { + $creditResponse = $creditService->cancelAuthorize($transaction->payment_number); + } else { + // 部分返金(差額返金) + $creditResponse = $creditService->refund($transaction->payment_number, $afterRefundPayAmount); + } + } catch (RuntimeException $e) { + $mapped = $this->mapCreditExceptionToApiError($e); + // 与信取消失敗時はrefundで再試行 + if ($afterRefundPayAmount === 0 && str_contains($e->getMessage(), 'INVALID_OPERATION')) { + try { + $creditResponse = $creditService->refund($transaction->payment_number, 0); + } catch (RuntimeException $retryException) { + $retryMapped = $this->mapCreditExceptionToApiError($retryException); + return response()->json([ + 'error' => [ + 'code' => $retryMapped['code'], + 'message' => $retryMapped['message'], + ] + ], $retryMapped['code'] === 'E99' ? 500 : 400); + } + } else { + return response()->json([ + 'error' => [ + 'code' => $mapped['code'], + 'message' => $mapped['message'], + ] + ], $mapped['code'] === 'E99' ? 500 : 400); + } + } + + // トランザクション更新 + $refundId = 'REF-' . date('YmdHis') . '-' . substr(uniqid(), -6); + $refundStatus = $creditResponse['refundStatus'] ?? self::REFUND_STATUS_REFUNDED; + $transaction->update([ + 'refund_amount' => $refundAmount, + 'refund_status' => $refundStatus, + 'refund_id' => $refundId, + ]); + + $response = [ + 'SyunoRecvNum' => $transaction->syuno_recv_num, + 'refundStatus' => $refundStatus, + 'refundAmount' => $refundAmount, + 'originalAmount' => $transaction->amount, + 'refundId' => $refundId, + ]; + if ($transaction->payment_number) { + $response['transactionId'] = $transaction->payment_number; + } + + return response()->json($response); + } + + /** + * Wellnet/CAFISエラーをapi.pdfの返金APIエラーコードへ寄せる + */ + private function mapCreditExceptionToApiError(RuntimeException $e): array + { + $message = $e->getMessage(); + + if (preg_match('/\\bE29\\b/u', $message) || str_contains($message, '有効期限が過ぎ')) { + return ['code' => 'E29', 'message' => '期限経過: ' . $message]; + } + if (str_contains($message, 'PAYMENT_NUMBER_DOES_NOT_EXIST')) { + return ['code' => 'E20', 'message' => '取引不明: ' . $message]; + } + if (str_contains($message, 'INVALID_OPERATION_REFUNDED')) { + return ['code' => 'D01', 'message' => '返金不可: ' . $message]; + } + if (str_contains($message, 'INVALID_OPERATION_CANCELED')) { + return ['code' => 'D01', 'message' => '返金不可: ' . $message]; + } + if (preg_match('/\\bD0[2-8]\\b/u', $message) || str_contains($message, 'D81')) { + // D02: 売上前 / D03: 差額なし(取消推奨) / D04: 返金後金額不正 / D05: 対象なし + // D06-08: 二重等 / D81: 差額返金済の為取消不可 + return ['code' => 'E30', 'message' => '処理不可: ' . $message]; + } + if (str_contains($message, 'HTTP 423')) { + return ['code' => 'E30', 'message' => '処理不可: ' . $message]; + } + if (str_contains($message, 'USER_ACTION_IN_PROGRESS')) { + return ['code' => 'E30', 'message' => '処理不可: ' . $message]; + } + + return ['code' => 'E99', 'message' => '返金処理に失敗しました: ' . $message]; + } +} diff --git a/app/Http/Controllers/Api/RemPaymentController.php b/app/Http/Controllers/Api/RemPaymentController.php new file mode 100644 index 0000000..52ac129 --- /dev/null +++ b/app/Http/Controllers/Api/RemPaymentController.php @@ -0,0 +1,195 @@ +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 === '入金待ち') { + $paymentGuideUrl = $linkBuilder->buildRemUrl($existing->kessai_no); + return response()->json([ + 'result' => 'OK', + 'paymentGuideUrl' => $paymentGuideUrl, + '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']; + } + if (!empty($validated['SyunoMemberNum'])) { + $inData['SyunoMemberNum'] = $validated['SyunoMemberNum']; + } + + 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']; + + // 支払案内リンク生成 + $paymentGuideUrl = $linkBuilder->buildRemUrl($kessaiNo); + + // トランザクション保存 + PaymentTransaction::create([ + 'syuno_recv_num' => $validated['SyunoRecvNum'], + 'payment_type' => 'rem', + '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' => 0, + 'free_area' => $this->buildFreeAreaJson($validated), + 'wellnet_response' => json_encode($result, JSON_UNESCAPED_UNICODE), + ]); + + return response()->json([ + 'result' => 'OK', + 'paymentGuideUrl' => $paymentGuideUrl, + 'SyunoRecvNum' => $validated['SyunoRecvNum'], + ]); + } + + /** + * 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 + { + 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); + } +} diff --git a/app/Http/Middleware/WellnetIpWhitelist.php b/app/Http/Middleware/WellnetIpWhitelist.php new file mode 100644 index 0000000..693b292 --- /dev/null +++ b/app/Http/Middleware/WellnetIpWhitelist.php @@ -0,0 +1,44 @@ +environment('production')) { + return response('800', 200) + ->header('Content-Type', 'text/plain'); + } + return $next($request); + } + + $clientIp = $request->ip(); + + if (!in_array($clientIp, $allowedIps, true)) { + // Wellnet Callback仕様: 異常時もHTTP 200 + 固定コードで応答 + return response('800', 200) + ->header('Content-Type', 'text/plain'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Api/CreditLinkRequest.php b/app/Http/Requests/Api/CreditLinkRequest.php new file mode 100644 index 0000000..3a9ce59 --- /dev/null +++ b/app/Http/Requests/Api/CreditLinkRequest.php @@ -0,0 +1,106 @@ + ['required', 'string', 'max:20', 'regex:/^[a-zA-Z0-9]+$/'], + 'SyunoTel' => ['required', 'string', 'max:13', 'regex:/^[0-9\-]+$/'], + 'SyunoNameKanji' => ['required', 'string', 'max:40'], + 'SyunoNameKana' => ['nullable', 'string', 'max:40'], + 'SyunoPayLimit' => ['required', 'string', 'regex:/^\d{12}$/'], + 'SyunoPayAmount' => ['required', 'integer', 'min:1'], + 'SyunoFree1' => ['required', 'string', 'max:32'], + 'SyunoFree9' => ['required', 'string', 'max:60'], + 'SyunoFree19' => ['required', 'string', 'max:42'], + 'subscription_flg' => ['required', 'integer', 'in:0,1'], + 'SyunoReserveNum' => ['nullable', 'string', 'max:20'], + 'SyunoFree2' => ['nullable', 'string', 'max:32'], + 'SyunoFree3' => ['nullable', 'string', 'max:32'], + 'SyunoFree4' => ['nullable', 'string', 'max:32'], + 'SyunoFree5' => ['nullable', 'string', 'max:32'], + 'SyunoFree6' => ['nullable', 'string', 'max:32'], + 'SyunoFree7' => ['nullable', 'string', 'max:32'], + 'SyunoFree8' => ['nullable', 'string', 'max:32'], + 'SyunoFree10' => ['nullable', 'string', 'max:60'], + 'SyunoFree11' => ['nullable', 'string', 'max:60'], + 'SyunoFree12' => ['nullable', 'string', 'max:60'], + 'SyunoFree13' => ['nullable', 'string', 'max:60'], + 'SyunoFree14' => ['nullable', 'string', 'max:60'], + 'SyunoFree15' => ['nullable', 'string', 'max:60'], + 'SyunoFree16' => ['nullable', 'string', 'max:60'], + 'SyunoFree17' => ['nullable', 'string', 'max:60'], + 'SyunoFree18' => ['nullable', 'string', 'max:60'], + 'SyunoFree20' => ['nullable', 'string', 'max:12'], + 'SyunoFree21' => ['nullable', 'string', 'max:11'], + 'SyunoFree22' => ['nullable', 'string', 'max:128'], + 'SyunoFree23' => ['nullable', 'string', 'max:40'], + ]; + } + + /** + * バリデーション失敗時のJSONエラーレスポンス + */ + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => $validator->errors()->first(), + ] + ], 400)); + } + + /** + * 追加バリデーション(支払期限の範囲チェック) + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $payLimit = $this->input('SyunoPayLimit'); + if (!$payLimit) { + return; + } + + try { + $dt = Carbon::createFromFormat('YmdHi', $payLimit, 'Asia/Tokyo'); + $errors = Carbon::getLastErrors() ?: ['warning_count' => 0, 'error_count' => 0]; + if (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + } catch (\Throwable) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + + $now = Carbon::now('Asia/Tokyo'); + $max = $now->copy()->addDays(365); + + if ($dt->lessThanOrEqualTo($now)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは現在時刻より未来を指定してください。'); + } elseif ($dt->greaterThan($max)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは365日以内で指定してください。'); + } + }); + } +} diff --git a/app/Http/Requests/Api/PaymentUpdateRequest.php b/app/Http/Requests/Api/PaymentUpdateRequest.php new file mode 100644 index 0000000..9395c2e --- /dev/null +++ b/app/Http/Requests/Api/PaymentUpdateRequest.php @@ -0,0 +1,103 @@ + ['required', 'string', 'max:20', 'regex:/^[a-zA-Z0-9]+$/'], + 'cancelFlag' => ['nullable'], + 'SyunoPayLimit' => ['nullable', 'string', 'regex:/^\d{12}$/'], + 'SyunoPayAmount' => ['nullable', 'integer', 'min:1'], + 'SyunoNameKanji' => ['nullable', 'string', 'max:40'], + 'SyunoNameKana' => ['nullable', 'string', 'max:40'], + 'SyunoTel' => ['nullable', 'string', 'max:13', 'regex:/^[0-9\\-]+$/'], + 'SyunoFree1' => ['nullable', 'string', 'max:32'], + 'SyunoFree2' => ['nullable', 'string', 'max:32'], + 'SyunoFree3' => ['nullable', 'string', 'max:32'], + 'SyunoFree4' => ['nullable', 'string', 'max:32'], + 'SyunoFree5' => ['nullable', 'string', 'max:32'], + 'SyunoFree6' => ['nullable', 'string', 'max:32'], + 'SyunoFree7' => ['nullable', 'string', 'max:32'], + 'SyunoFree8' => ['nullable', 'string', 'max:32'], + 'SyunoFree9' => ['nullable', 'string', 'max:60'], + 'SyunoFree10' => ['nullable', 'string', 'max:60'], + 'SyunoFree11' => ['nullable', 'string', 'max:60'], + 'SyunoFree12' => ['nullable', 'string', 'max:60'], + 'SyunoFree13' => ['nullable', 'string', 'max:60'], + 'SyunoFree14' => ['nullable', 'string', 'max:60'], + 'SyunoFree15' => ['nullable', 'string', 'max:60'], + 'SyunoFree16' => ['nullable', 'string', 'max:60'], + 'SyunoFree17' => ['nullable', 'string', 'max:60'], + 'SyunoFree18' => ['nullable', 'string', 'max:60'], + 'SyunoFree19' => ['nullable', 'string', 'max:42'], + 'SyunoFree20' => ['nullable', 'string', 'max:12'], + 'SyunoFree21' => ['nullable', 'string', 'max:11'], + ]; + } + + /** + * バリデーション失敗時のJSONエラーレスポンス + */ + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => $validator->errors()->first(), + ] + ], 400)); + } + + /** + * 追加バリデーション(支払期限の範囲チェック) + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $payLimit = $this->input('SyunoPayLimit'); + if ($payLimit === null) { + return; + } + + try { + $dt = Carbon::createFromFormat('YmdHi', $payLimit, 'Asia/Tokyo'); + $errors = Carbon::getLastErrors() ?: ['warning_count' => 0, 'error_count' => 0]; + if (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + } catch (\Throwable) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + + $now = Carbon::now('Asia/Tokyo'); + $max = $now->copy()->addDays(365); + + if ($dt->lessThanOrEqualTo($now)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは現在時刻より未来を指定してください。'); + } elseif ($dt->greaterThan($max)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは365日以内で指定してください。'); + } + }); + } +} diff --git a/app/Http/Requests/Api/RefundRequest.php b/app/Http/Requests/Api/RefundRequest.php new file mode 100644 index 0000000..66e56a4 --- /dev/null +++ b/app/Http/Requests/Api/RefundRequest.php @@ -0,0 +1,42 @@ + ['required', 'string', 'max:20', 'regex:/^[a-zA-Z0-9]+$/'], + 'refundAmount' => ['nullable', 'integer', 'min:1'], + ]; + } + + /** + * バリデーション失敗時のJSONエラーレスポンス + */ + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => $validator->errors()->first(), + ] + ], 400)); + } +} diff --git a/app/Http/Requests/Api/RemLinkRequest.php b/app/Http/Requests/Api/RemLinkRequest.php new file mode 100644 index 0000000..49d143f --- /dev/null +++ b/app/Http/Requests/Api/RemLinkRequest.php @@ -0,0 +1,106 @@ + ['required', 'string', 'max:20', 'regex:/^[a-zA-Z0-9]+$/'], + 'SyunoTel' => ['required', 'string', 'max:13', 'regex:/^[0-9\-]+$/'], + 'SyunoNameKanji' => ['required', 'string', 'max:40'], + 'SyunoNameKana' => ['nullable', 'string', 'max:40'], + 'SyunoPayLimit' => ['required', 'string', 'regex:/^\d{12}$/'], + 'SyunoPayAmount' => ['required', 'integer', 'min:1'], + 'SyunoFree1' => ['required', 'string', 'max:32'], + 'SyunoFree9' => ['required', 'string', 'max:60'], + 'SyunoFree19' => ['required', 'string', 'max:42'], + 'SyunoMemberNum' => ['nullable', 'string', 'max:20'], + 'SyunoReserveNum' => ['nullable', 'string', 'max:20'], + 'SyunoFree2' => ['nullable', 'string', 'max:32'], + 'SyunoFree3' => ['nullable', 'string', 'max:32'], + 'SyunoFree4' => ['nullable', 'string', 'max:32'], + 'SyunoFree5' => ['nullable', 'string', 'max:32'], + 'SyunoFree6' => ['nullable', 'string', 'max:32'], + 'SyunoFree7' => ['nullable', 'string', 'max:32'], + 'SyunoFree8' => ['nullable', 'string', 'max:32'], + 'SyunoFree10' => ['nullable', 'string', 'max:60'], + 'SyunoFree11' => ['nullable', 'string', 'max:60'], + 'SyunoFree12' => ['nullable', 'string', 'max:60'], + 'SyunoFree13' => ['nullable', 'string', 'max:60'], + 'SyunoFree14' => ['nullable', 'string', 'max:60'], + 'SyunoFree15' => ['nullable', 'string', 'max:60'], + 'SyunoFree16' => ['nullable', 'string', 'max:60'], + 'SyunoFree17' => ['nullable', 'string', 'max:60'], + 'SyunoFree18' => ['nullable', 'string', 'max:60'], + 'SyunoFree20' => ['nullable', 'string', 'max:12'], + 'SyunoFree21' => ['nullable', 'string', 'max:11'], + 'SyunoFree22' => ['nullable', 'string', 'max:128'], + 'SyunoFree23' => ['nullable', 'string', 'max:40'], + ]; + } + + /** + * バリデーション失敗時のJSONエラーレスポンス + */ + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => $validator->errors()->first(), + ] + ], 400)); + } + + /** + * 追加バリデーション(支払期限の範囲チェック) + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $payLimit = $this->input('SyunoPayLimit'); + if (!$payLimit) { + return; + } + + try { + $dt = Carbon::createFromFormat('YmdHi', $payLimit, 'Asia/Tokyo'); + $errors = Carbon::getLastErrors() ?: ['warning_count' => 0, 'error_count' => 0]; + if (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + } catch (\Throwable) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitの日時が不正です。'); + return; + } + + $now = Carbon::now('Asia/Tokyo'); + $max = $now->copy()->addDays(365); + + if ($dt->lessThanOrEqualTo($now)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは現在時刻より未来を指定してください。'); + } elseif ($dt->greaterThan($max)) { + $validator->errors()->add('SyunoPayLimit', 'SyunoPayLimitは365日以内で指定してください。'); + } + }); + } +} diff --git a/app/Http/Requests/Api/SubscriptionChargeRequest.php b/app/Http/Requests/Api/SubscriptionChargeRequest.php new file mode 100644 index 0000000..74473a3 --- /dev/null +++ b/app/Http/Requests/Api/SubscriptionChargeRequest.php @@ -0,0 +1,69 @@ + ['required', 'string', 'max:20'], + 'amount' => ['required', 'integer', 'min:1'], + 'execute_date' => ['required', 'string', 'regex:/^\d{8}$/'], + 'contract_id' => ['required', 'string', 'max:20'], + 'card_seq' => ['nullable', 'integer'], + ]; + } + + /** + * バリデーション失敗時のJSONエラーレスポンス + */ + protected function failedValidation(Validator $validator): void + { + throw new HttpResponseException(response()->json([ + 'error' => [ + 'code' => 'INVALID_REQUEST', + 'message' => $validator->errors()->first(), + ] + ], 400)); + } + + /** + * 追加バリデーション(execute_dateの実在日チェック) + */ + public function withValidator($validator): void + { + $validator->after(function ($validator) { + $executeDate = $this->input('execute_date'); + if (!$executeDate) { + return; + } + + try { + Carbon::createFromFormat('Ymd', $executeDate, 'Asia/Tokyo'); + $errors = Carbon::getLastErrors() ?: ['warning_count' => 0, 'error_count' => 0]; + if (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0) { + $validator->errors()->add('execute_date', 'execute_dateの日付が不正です。'); + } + } catch (\Throwable) { + $validator->errors()->add('execute_date', 'execute_dateの日付が不正です。'); + } + }); + } +} diff --git a/app/Models/CreditPaymentLog.php b/app/Models/CreditPaymentLog.php new file mode 100644 index 0000000..f78e807 --- /dev/null +++ b/app/Models/CreditPaymentLog.php @@ -0,0 +1,52 @@ + 'date', + 'request_at' => 'datetime', + 'request_amount' => 'decimal:0', + ]; + + /** + * 継続課金登録リレーション + */ + public function creditSubscription(): BelongsTo + { + return $this->belongsTo(CreditSubscription::class, 'credit_subscription_id', 'credit_subscription_id'); + } + + /** + * 利用者リレーション + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'user_id'); + } +} diff --git a/app/Models/CreditSubscription.php b/app/Models/CreditSubscription.php new file mode 100644 index 0000000..da13673 --- /dev/null +++ b/app/Models/CreditSubscription.php @@ -0,0 +1,38 @@ + 'datetime', + 'subscription_status' => 'integer', + ]; + + /** + * 利用者リレーション + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'user_id'); + } +} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php new file mode 100644 index 0000000..ea10782 --- /dev/null +++ b/app/Models/PaymentTransaction.php @@ -0,0 +1,46 @@ + 'array', + 'paid_datetime' => 'datetime', + 'pay_limit' => 'datetime', + 'amount' => 'integer', + 'subscription_flg' => 'integer', + 'refund_amount' => 'integer', + ]; +} diff --git a/app/Services/Wellnet/PaymentLinkBuilder.php b/app/Services/Wellnet/PaymentLinkBuilder.php new file mode 100644 index 0000000..ffc6769 --- /dev/null +++ b/app/Services/Wellnet/PaymentLinkBuilder.php @@ -0,0 +1,34 @@ +baseUrl = config('wellnet.credit_api.base_url'); + $this->companyCode = config('wellnet.credit_api.company_code'); + $this->authKey = config('wellnet.credit_api.auth_key'); + $this->timeout = config('wellnet.credit_api.timeout', 120); + } + + /** + * 与信照会要求(Authorize) + * + * @param string $memberNo 会員番号 + * @param int $payAmount 決済金額 + * @param int|null $cardNoSeq カード連番(未指定時はデフォルト使用) + * @return array レスポンスデータ + * @throws RuntimeException APIエラー時 + */ + public function authorize(string $memberNo, int $payAmount, ?int $cardNoSeq = null): array + { + $payload = [ + 'requestId' => $this->generateRequestId(), + 'companyCode' => $this->companyCode, + 'authKey' => $this->authKey, + 'memberNo' => $memberNo, + 'payAmount' => $payAmount, + ]; + + if ($cardNoSeq !== null) { + $payload['cardNoSeq'] = $cardNoSeq; + } + + return $this->post('/api/authori', $payload); + } + + /** + * 売上確定要求(Capture) + * + * @param string $paymentNumber 決済番号 + * @return array レスポンスデータ + * @throws RuntimeException APIエラー時 + */ + public function capture(string $paymentNumber): array + { + $payload = [ + 'requestId' => $this->generateRequestId(), + 'companyCode' => $this->companyCode, + 'authKey' => $this->authKey, + 'paymentNumber' => $paymentNumber, + ]; + + return $this->post('/api/definiteTanking', $payload); + } + + /** + * 与信取消要求(全額取消) + * + * @param string $paymentNumber 決済番号 + * @return array レスポンスデータ + * @throws RuntimeException APIエラー時 + */ + public function cancelAuthorize(string $paymentNumber): array + { + $payload = [ + 'requestId' => $this->generateRequestId(), + 'companyCode' => $this->companyCode, + 'authKey' => $this->authKey, + 'paymentNumber' => $paymentNumber, + ]; + + try { + return $this->post('/api/cancelAuthori', $payload); + } catch (RuntimeException $e) { + if ((int) $e->getCode() === 404) { + return $this->post('/api/cancelPayment', $payload); + } + throw $e; + } + } + + /** + * 差額返金要求(部分返金/全額返金) + * + * @param string $paymentNumber 決済番号 + * @param int $afterRefundPayAmount 返金後の残額(全額返金時は0) + * @return array レスポンスデータ + * @throws RuntimeException APIエラー時 + */ + public function refund(string $paymentNumber, int $afterRefundPayAmount): array + { + $payload = [ + 'requestId' => $this->generateRequestId(), + 'companyCode' => $this->companyCode, + 'authKey' => $this->authKey, + 'paymentNumber' => $paymentNumber, + 'afterRefundPayAmount' => $afterRefundPayAmount, + ]; + + try { + return $this->post('/api/refund', $payload); + } catch (RuntimeException $e) { + if ((int) $e->getCode() === 404) { + return $this->post('/api/refundPayment', $payload); + } + throw $e; + } + } + + /** + * HTTP POST送信 + * + * @param string $path APIパス + * @param array $payload リクエストボディ + * @return array レスポンスデータ + * @throws RuntimeException 通信エラー・APIエラー時 + */ + private function post(string $path, array $payload): array + { + $url = rtrim($this->baseUrl, '/') . $path; + + $response = Http::timeout($this->timeout) + ->acceptJson() + ->post($url, $payload); + + if (!$response->successful()) { + $body = $response->json() ?? []; + $errorDesc = $body['errorDescription'] ?? $body['result'] ?? 'HTTP ' . $response->status(); + throw new RuntimeException( + 'クレジットAPIエラー: ' . $errorDesc, + $response->status() + ); + } + + $data = $response->json(); + + if (isset($data['result']) && $data['result'] !== 'SUCCESS') { + throw new RuntimeException( + 'クレジットAPIエラー: ' . ($data['errorDescription'] ?? $data['result']), + 400 + ); + } + + return $data; + } + + /** + * リクエストID自動採番 + * + * @return string 一意のリクエストID + */ + private function generateRequestId(): string + { + return 'REQ-' . date('YmdHis') . '-' . Str::random(6); + } +} diff --git a/app/Services/Wellnet/WellnetSoapService.php b/app/Services/Wellnet/WellnetSoapService.php new file mode 100644 index 0000000..8cd80ed --- /dev/null +++ b/app/Services/Wellnet/WellnetSoapService.php @@ -0,0 +1,218 @@ +wsdlPath = config('wellnet.soap.wsdl_path'); + $this->endpoint = config('wellnet.soap.endpoint'); + $this->namespace = config('wellnet.soap.namespace'); + $this->encoding = config('wellnet.soap.encoding'); + $this->userId = config('wellnet.soap.user_id'); + $this->password = config('wellnet.soap.password'); + } + + /** + * 受付データ登録(SyunoOpCode='I') + * + * @param array $inData 送信データ + * @return array レスポンスデータ + * @throws RuntimeException Wellnetエラー時 + */ + public function register(array $inData): array + { + $inData['SyunoOpCode'] = 'I'; + return $this->execute($inData); + } + + /** + * 受付データ更新(SyunoOpCode='U') + * + * @param array $inData 送信データ + * @return array レスポンスデータ + * @throws RuntimeException Wellnetエラー時 + */ + public function update(array $inData): array + { + $inData['SyunoOpCode'] = 'U'; + return $this->execute($inData); + } + + /** + * 受付データ取消(SyunoOpCode='D') + * + * @param string $syunoRecvNum 受付番号 + * @return array レスポンスデータ + * @throws RuntimeException Wellnetエラー時 + */ + public function cancel(string $syunoRecvNum): array + { + $inData = [ + 'SyunoRecvNum' => $syunoRecvNum, + 'SyunoOpCode' => 'D', + ]; + return $this->execute($inData); + } + + /** + * SOAP通信実行 + * + * @param array $inData 送信データ + * @return array レスポンスデータ + * @throws RuntimeException 通信エラー・Wellnetエラー時 + */ + private function execute(array $inData): array + { + if (!file_exists($this->wsdlPath)) { + throw new RuntimeException('WSDLファイルが見つかりません: ' . $this->wsdlPath); + } + + // 固定値の設定 + $inData['DataSyubetsu'] = config('wellnet.payment.data_syubetsu'); + + if (empty($inData['SyunoPayCode'])) { + $inData['SyunoPayCode'] = config('wellnet.payment.pay_code'); + } + if (empty($inData['SyunoCorpCode'])) { + $inData['SyunoCorpCode'] = config('wellnet.payment.corp_code'); + } + if (empty($inData['BcJigyosyaNo'])) { + $inData['BcJigyosyaNo'] = config('wellnet.payment.jigyosya_no'); + } + if (empty($inData['BcAnkenNo'])) { + $inData['BcAnkenNo'] = config('wellnet.payment.anken_no'); + } + if (empty($inData['BcNinsyoKey'])) { + $inData['BcNinsyoKey'] = config('wellnet.payment.ninsyo_key'); + } + if (empty($inData['SyunoServiceKey'])) { + $inData['SyunoServiceKey'] = config('wellnet.payment.service_key'); + } + + // 日本語文字列をShift_JISに変換 + $inData = $this->convertToSjis($inData); + + // SyunoFreeArray の構築 + if (isset($inData['SyunoFreeArray']) && is_array($inData['SyunoFreeArray'])) { + $freeArray = []; + foreach ($inData['SyunoFreeArray'] as $index => $value) { + $freeArray[] = ['Index' => $index, 'SyunoFreeStr' => $value]; + } + $inData['SyunoFreeArray'] = $freeArray; + } + + try { + $client = new SoapClient($this->wsdlPath, [ + 'encoding' => $this->encoding, + 'trace' => true, + 'exceptions' => true, + 'cache_wsdl' => WSDL_CACHE_NONE, + 'connection_timeout' => 30, + 'location' => $this->endpoint, + 'stream_context' => stream_context_create([ + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ] + ]), + ]); + + // SOAP認証ヘッダ設定 + $header = new SoapHeader($this->namespace, 'WellnetSoapHeader', [ + 'UserId' => $this->userId, + 'Password' => $this->password, + ], true); + $client->__setSoapHeaders($header); + + // SOAP呼出 + $params = ['inData' => $inData]; + $response = $client->YoyakuSyunoBarCode($params); + + // レスポンス解析 + $result = $this->parseResponse($response); + + // 結果チェック + if ($result['Result'] !== '0000') { + throw new WellnetSoapException( + $result['Result'], + 'Wellnet SOAPエラー: Result=' . $result['Result'] + ); + } + + return $result; + + } catch (\SoapFault $e) { + throw new RuntimeException('SOAP通信エラー: ' . $e->getMessage(), 0, $e); + } + } + + /** + * SOAPレスポンス解析 + * + * @param object $response SOAPレスポンスオブジェクト + * @return array 解析済みデータ + */ + private function parseResponse(object $response): array + { + $result = $response->YoyakuSyunoBarCodeResult; + + return [ + 'Result' => $result->Result ?? '', + 'DataSyubetsu' => $result->DataSyubetsu ?? '', + 'KKessaiNo' => $result->KKessaiNo ?? '', + 'FreeArea' => $result->FreeArea ?? '', + 'SyunoPayCode' => $result->SyunoPayCode ?? '', + 'SyunoRecvNum' => $result->SyunoRecvNum ?? '', + 'BcJigyosyaNo' => $result->BcJigyosyaNo ?? '', + 'BcAnkenNo' => $result->BcAnkenNo ?? '', + 'BcNinsyoKey' => $result->BcNinsyoKey ?? '', + 'SyunoMMSNo' => $result->SyunoMMSNo ?? '', + ]; + } + + /** + * 文字列値をShift_JISに変換 + * + * @param array $data 変換対象データ + * @return array 変換済みデータ + */ + private function convertToSjis(array $data): array + { + // 変換対象フィールド(日本語が含まれる可能性のあるフィールド) + $targetFields = ['SyunoNameKanji', 'SyunoNameKana']; + + foreach ($targetFields as $field) { + if (isset($data[$field]) && $data[$field] !== '') { + $data[$field] = mb_convert_encoding($data[$field], 'SJIS', 'UTF-8'); + } + } + + // SyunoFreeArray内の値も変換 + if (isset($data['SyunoFreeArray']) && is_array($data['SyunoFreeArray'])) { + foreach ($data['SyunoFreeArray'] as $index => $value) { + if (is_string($value) && $value !== '' && $value !== '*') { + $data['SyunoFreeArray'][$index] = mb_convert_encoding($value, 'SJIS', 'UTF-8'); + } + } + } + + return $data; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 22e29ae..af2cbf7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) // APIキー認証ミドルウェアのエイリアス登録 $middleware->alias([ 'api.key' => \App\Http\Middleware\ApiKeyAuthentication::class, + 'wellnet.ip' => \App\Http\Middleware\WellnetIpWhitelist::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/config/wellnet.php b/config/wellnet.php new file mode 100644 index 0000000..4224582 --- /dev/null +++ b/config/wellnet.php @@ -0,0 +1,118 @@ + $env, + + /* + |-------------------------------------------------------------------------- + | アプリ側運用設定 + |-------------------------------------------------------------------------- + */ + 'link_ttl_minutes' => (int) env('WELLNET_LINK_TTL_MINUTES', 15), + + /* + |-------------------------------------------------------------------------- + | SOAP接続情報(YoyakuSyunoBarCode) + |-------------------------------------------------------------------------- + */ + 'soap' => [ + 'user_id' => env('WELLNET_SOAP_USER', ''), + 'password' => env('WELLNET_SOAP_PASSWORD', ''), + 'endpoint' => env("WELLNET_SOAP_ENDPOINT_" . strtoupper($env), ''), + 'namespace' => env('WELLNET_SOAP_NAMESPACE', 'http://rem.kessai.info/Kessai/'), + 'wsdl_path' => storage_path('app/wellnet/Yoyaku.wsdl'), + 'encoding' => 'Shift_JIS', + ], + + /* + |-------------------------------------------------------------------------- + | 決済受付固定値(事業者情報) + |-------------------------------------------------------------------------- + */ + 'payment' => [ + 'data_syubetsu' => '4', + 'pay_code' => env('WELLNET_PAY_CODE', ''), + 'corp_code' => env('WELLNET_CORP_CODE', ''), + 'jigyosya_no' => env('WELLNET_JIGYOSYA_NO', ''), + 'anken_no' => env('WELLNET_ANKEN_NO', ''), + 'ninsyo_key' => env('WELLNET_NINSYO_KEY', ''), + 'service_key' => env('WELLNET_SERVICE_KEY', ''), + ], + + /* + |-------------------------------------------------------------------------- + | JLP画面URL(支払案内画面) + |-------------------------------------------------------------------------- + */ + 'jlp' => [ + 'base_url' => env("WELLNET_JLP_BASE_" . strtoupper($env), 'https://link.kessai.info'), + ], + + /* + |-------------------------------------------------------------------------- + | WPS画面URL(クレジット決済画面) + |-------------------------------------------------------------------------- + */ + 'wps' => [ + 'base_url' => env("WELLNET_WPS_BASE_" . strtoupper($env), 'https://wps.kessai.info'), + ], + + /* + |-------------------------------------------------------------------------- + | クレジットAPI(CAFIS外部インターフェース) + |-------------------------------------------------------------------------- + */ + 'credit_api' => [ + 'base_url' => env("WELLNET_CREDIT_API_BASE_" . strtoupper($env), ''), + 'company_code' => env('WELLNET_CREDIT_COMPANY_CODE', ''), + 'auth_key' => env('WELLNET_CREDIT_AUTH_KEY', ''), + 'timeout' => 120, + ], + + /* + |-------------------------------------------------------------------------- + | 入金通知(Callback)設定 + |-------------------------------------------------------------------------- + */ + 'callback' => [ + 'md5_secret' => env('WELLNET_MD5_SECRET_' . strtoupper($env), env('WELLNET_MD5_SECRET', '')), + 'allowed_ips' => array_filter(explode(',', env('WELLNET_ALLOWED_IPS_' . strtoupper($env), env('WELLNET_ALLOWED_IPS', '')))), + ], + + /* + |-------------------------------------------------------------------------- + | 店舗コードマッピング + |-------------------------------------------------------------------------- + */ + 'store_codes' => [ + '00011' => 'セブン-イレブン', + '00021' => 'ローソン', + '00031' => 'ファミリーマート', + '00032' => 'ファミリーマート', + '00051' => 'ミニストップ', + '00061' => 'デイリーヤマザキ', + '00071' => 'セイコーマート', + '10001' => 'ゆうちょ銀行ATM', + '10002' => '銀行ATM', + '99661' => 'JCBカード決済', + '99662' => 'VISAカード決済', + '99663' => 'Mastercardカード決済', + '99664' => 'AMEXカード決済', + '99665' => 'Dinersカード決済', + ], + +]; diff --git a/database/migrations/2026_01_23_000001_create_payment_transaction_table.php b/database/migrations/2026_01_23_000001_create_payment_transaction_table.php new file mode 100644 index 0000000..84664a9 --- /dev/null +++ b/database/migrations/2026_01_23_000001_create_payment_transaction_table.php @@ -0,0 +1,49 @@ +increments('payment_transaction_id')->comment('決済トランザクションID'); + $table->string('syuno_recv_num', 20)->unique()->comment('受付番号'); + $table->string('payment_type', 10)->comment('決済種別(credit/rem)'); + $table->string('status', 20)->default('入金待ち')->comment('決済ステータス'); + $table->integer('amount')->comment('決済金額'); + $table->datetime('pay_limit')->nullable()->comment('支払期限'); + $table->string('kessai_no', 255)->nullable()->comment('暗号化決済番号(KKessaiNo)'); + $table->string('payment_number', 50)->nullable()->comment('CAFIS決済番号(credit用)'); + $table->string('name_kanji', 40)->nullable()->comment('お客様氏名(漢字)'); + $table->string('name_kana', 40)->nullable()->comment('お客様氏名(カナ)'); + $table->string('tel', 13)->nullable()->comment('電話番号'); + $table->tinyInteger('subscription_flg')->default(0)->comment('継続課金フラグ'); + $table->datetime('paid_datetime')->nullable()->comment('入金日時'); + $table->string('store_code', 10)->nullable()->comment('店舗コード'); + $table->string('cvs_code', 5)->nullable()->comment('CVS本部コード'); + $table->integer('refund_amount')->nullable()->comment('返金済金額'); + $table->string('refund_status', 20)->nullable()->comment('返金ステータス'); + $table->string('refund_id', 50)->nullable()->comment('返金処理ID'); + $table->json('free_area')->nullable()->comment('フリースペース(JSON)'); + $table->text('wellnet_response')->nullable()->comment('Wellnet応答生データ'); + $table->timestamps(); + + $table->index('status', 'idx_payment_transaction_status'); + $table->index('payment_type', 'idx_payment_transaction_type'); + }); + } + + /** + * テーブル削除 + */ + public function down(): void + { + Schema::dropIfExists('payment_transaction'); + } +}; diff --git a/database/migrations/2026_01_23_000002_create_credit_subscription_table.php b/database/migrations/2026_01_23_000002_create_credit_subscription_table.php new file mode 100644 index 0000000..07388f7 --- /dev/null +++ b/database/migrations/2026_01_23_000002_create_credit_subscription_table.php @@ -0,0 +1,37 @@ +increments('credit_subscription_id')->comment('継続課金登録ID'); + $table->unsignedInteger('user_id')->comment('利用者ID'); + $table->string('credit_member_id', 20)->nullable()->comment('Wellnet側会員ID'); + $table->unsignedInteger('credit_card_seq')->nullable()->comment('カード通番'); + $table->tinyInteger('subscription_status')->nullable()->comment('登録状態(1=有効、0=無効/解除)'); + $table->datetime('registered_at')->nullable()->comment('初回登録日時'); + $table->timestamps(); + $table->unsignedInteger('operator_id')->nullable()->comment('更新オペレータID'); + + $table->index('user_id', 'idx_credit_subscription_user_id'); + $table->index('subscription_status', 'idx_credit_subscription_status'); + $table->foreign('user_id')->references('user_id')->on('user'); + }); + } + + /** + * テーブル削除 + */ + public function down(): void + { + Schema::dropIfExists('credit_subscription'); + } +}; diff --git a/database/migrations/2026_01_23_000003_create_credit_payment_log_table.php b/database/migrations/2026_01_23_000003_create_credit_payment_log_table.php new file mode 100644 index 0000000..0068e2a --- /dev/null +++ b/database/migrations/2026_01_23_000003_create_credit_payment_log_table.php @@ -0,0 +1,45 @@ +increments('credit_payment_log_id')->comment('請求履歴ID'); + $table->unsignedInteger('credit_subscription_id')->nullable()->comment('継続課金登録ID'); + $table->string('contract_id', 20)->nullable()->comment('定期契約ID'); + $table->unsignedInteger('user_id')->nullable()->comment('利用者ID'); + $table->decimal('request_amount', 10, 0)->nullable()->comment('請求金額'); + $table->date('execute_date')->nullable()->comment('継続課金実施日'); + $table->datetime('request_at')->nullable()->comment('API請求日時'); + $table->string('response_result', 10)->nullable()->comment('応答結果(OK/NG)'); + $table->string('response_code', 10)->nullable()->comment('エラーコード'); + $table->string('response_message', 255)->nullable()->comment('エラーメッセージ'); + $table->string('payment_number', 50)->nullable()->comment('CAFIS決済番号'); + $table->string('status', 20)->nullable()->comment('処理状態(success/error/pending)'); + $table->timestamps(); + + $table->index('credit_subscription_id', 'idx_credit_payment_log_subscription'); + $table->index('contract_id', 'idx_credit_payment_log_contract'); + $table->index('user_id', 'idx_credit_payment_log_user'); + $table->index('execute_date', 'idx_credit_payment_log_execute_date'); + $table->foreign('credit_subscription_id')->references('credit_subscription_id')->on('credit_subscription'); + $table->foreign('user_id')->references('user_id')->on('user'); + }); + } + + /** + * テーブル削除 + */ + public function down(): void + { + Schema::dropIfExists('credit_payment_log'); + } +}; diff --git a/routes/api.php b/routes/api.php index 0d76ac1..d6c30bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,12 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\Api\UserInformationHistoryController; +use App\Http\Controllers\Api\CreditPaymentController; +use App\Http\Controllers\Api\RemPaymentController; +use App\Http\Controllers\Api\PaymentCallbackController; +use App\Http\Controllers\Api\PaymentStatusController; +use App\Http\Controllers\Api\PaymentUpdateController; +use App\Http\Controllers\Api\RefundController; /* |-------------------------------------------------------------------------- @@ -35,4 +41,40 @@ Route::middleware(['api.key'])->group(function () { Route::put('user-information-history/{id}', [UserInformationHistoryController::class, 'update']) ->where('id', '[0-9]+'); + /* + |-------------------------------------------------------------------------- + | API 1, 2, 4, 5, 6 - 決済API(API Key認証あり) + |-------------------------------------------------------------------------- + | + | POST /api/newwipe/credit/link - クレジット支払リンク生成 + | POST /api/newwipe/credit/subscription/charge - 継続課金請求 + | POST /api/newwipe/rem/link - REM支払案内リンク生成 + | GET /api/newwipe/status - 決済ステータス取得 + | PUT /api/newwipe/update - 決済情報更新 + | POST /api/newwipe/refund - 返金 + | + */ + Route::prefix('newwipe')->group(function () { + Route::post('credit/link', [CreditPaymentController::class, 'createLink']); + Route::post('credit/subscription/charge', [CreditPaymentController::class, 'chargeSubscription']); + Route::post('rem/link', [RemPaymentController::class, 'createLink']); + Route::get('status', [PaymentStatusController::class, 'show']); + Route::match(['put', 'post'], 'update', [PaymentUpdateController::class, 'update']); + Route::post('refund', [RefundController::class, 'refund']); + }); + +}); + +/* +|-------------------------------------------------------------------------- +| API 3 - 決済結果通知(Callback) +|-------------------------------------------------------------------------- +| +| POST /api/newwipe/callback - Wellnet入金通知受信 +| +| 認証: IP白名単のみ(API Key不要) +| +*/ +Route::middleware(['wellnet.ip'])->group(function () { + Route::match(['get', 'post'], 'newwipe/callback', [PaymentCallbackController::class, 'receive']); }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1..a6cc0f4 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,11 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// 支払期限切れチェック(15分毎) +Schedule::command('payment:expire')->everyFifteenMinutes();