Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0b4acd7475
commit
f139a3f608
27
app/Console/Commands/ExpirePaymentTransactions.php
Normal file
27
app/Console/Commands/ExpirePaymentTransactions.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 支払期限切れトランザクションのステータス更新バッチ
|
||||
*/
|
||||
class ExpirePaymentTransactions extends Command
|
||||
{
|
||||
protected $signature = 'payment:expire';
|
||||
protected $description = '支払期限切れトランザクションのステータスを更新する';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = PaymentTransaction::where('status', '入金待ち')
|
||||
->whereNotNull('pay_limit')
|
||||
->where('pay_limit', '<', now())
|
||||
->update(['status' => '支払期限切れ']);
|
||||
|
||||
$this->info("支払期限切れ更新: {$count}件");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
27
app/Exceptions/WellnetSoapException.php
Normal file
27
app/Exceptions/WellnetSoapException.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wellnet SOAPエラー例外
|
||||
*/
|
||||
class WellnetSoapException extends RuntimeException
|
||||
{
|
||||
private string $wellnetCode;
|
||||
|
||||
public function __construct(string $wellnetCode, string $message = '', ?\Throwable $previous = null)
|
||||
{
|
||||
$this->wellnetCode = $wellnetCode;
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wellnetエラーコード取得
|
||||
*/
|
||||
public function getWellnetCode(): string
|
||||
{
|
||||
return $this->wellnetCode;
|
||||
}
|
||||
}
|
||||
327
app/Http/Controllers/Api/CreditPaymentController.php
Normal file
327
app/Http/Controllers/Api/CreditPaymentController.php
Normal file
@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\WellnetSoapException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\CreditLinkRequest;
|
||||
use App\Http\Requests\Api\SubscriptionChargeRequest;
|
||||
use App\Models\CreditPaymentLog;
|
||||
use App\Models\CreditSubscription;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Services\Wellnet\PaymentLinkBuilder;
|
||||
use App\Services\Wellnet\WellnetCreditService;
|
||||
use App\Services\Wellnet\WellnetSoapService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* クレジット決済コントローラ(API 1.1 + 1.2)
|
||||
*/
|
||||
class CreditPaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* クレジット支払リンク生成(API 1.1)
|
||||
*
|
||||
* POST /api/newwipe/credit/link
|
||||
*/
|
||||
public function createLink(
|
||||
CreditLinkRequest $request,
|
||||
WellnetSoapService $soapService,
|
||||
PaymentLinkBuilder $linkBuilder
|
||||
): JsonResponse {
|
||||
$validated = $request->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];
|
||||
}
|
||||
}
|
||||
218
app/Http/Controllers/Api/PaymentCallbackController.php
Normal file
218
app/Http/Controllers/Api/PaymentCallbackController.php
Normal file
@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentTransaction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* 決済結果通知(callback)受信コントローラ(API 3)
|
||||
*/
|
||||
class PaymentCallbackController extends Controller
|
||||
{
|
||||
/**
|
||||
* パラメータマッピング(短縮名 → 論理名)
|
||||
*/
|
||||
private const PARAM_MAP = [
|
||||
'ret' => '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;
|
||||
}
|
||||
}
|
||||
82
app/Http/Controllers/Api/PaymentStatusController.php
Normal file
82
app/Http/Controllers/Api/PaymentStatusController.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentTransaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 決済ステータス取得コントローラ(API 4)
|
||||
*/
|
||||
class PaymentStatusController extends Controller
|
||||
{
|
||||
/**
|
||||
* 決済ステータス取得
|
||||
*
|
||||
* GET /api/newwipe/status?SyunoRecvNum=...
|
||||
*/
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$syunoRecvNum = $request->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);
|
||||
}
|
||||
}
|
||||
290
app/Http/Controllers/Api/PaymentUpdateController.php
Normal file
290
app/Http/Controllers/Api/PaymentUpdateController.php
Normal file
@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\WellnetSoapException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\PaymentUpdateRequest;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Services\Wellnet\WellnetSoapService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 決済情報更新コントローラ(API 5)
|
||||
*/
|
||||
class PaymentUpdateController extends Controller
|
||||
{
|
||||
/**
|
||||
* 決済情報更新/取消
|
||||
*
|
||||
* PUT /api/newwipe/update
|
||||
*/
|
||||
public function update(
|
||||
PaymentUpdateRequest $request,
|
||||
WellnetSoapService $soapService
|
||||
): JsonResponse {
|
||||
$validated = $request->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);
|
||||
}
|
||||
}
|
||||
186
app/Http/Controllers/Api/RefundController.php
Normal file
186
app/Http/Controllers/Api/RefundController.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\RefundRequest;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Services\Wellnet\WellnetCreditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 返金コントローラ(API 6)
|
||||
*/
|
||||
class RefundController extends Controller
|
||||
{
|
||||
private const REFUND_STATUS_REFUNDED = 'REFUNDED';
|
||||
private const REFUND_STATUS_PENDING = 'REFUND_PENDING';
|
||||
|
||||
/**
|
||||
* 返金処理
|
||||
*
|
||||
* POST /api/newwipe/refund
|
||||
*/
|
||||
public function refund(
|
||||
RefundRequest $request,
|
||||
WellnetCreditService $creditService
|
||||
): JsonResponse {
|
||||
$validated = $request->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];
|
||||
}
|
||||
}
|
||||
195
app/Http/Controllers/Api/RemPaymentController.php
Normal file
195
app/Http/Controllers/Api/RemPaymentController.php
Normal file
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\WellnetSoapException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\RemLinkRequest;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Services\Wellnet\PaymentLinkBuilder;
|
||||
use App\Services\Wellnet\WellnetSoapService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* REM支払案内コントローラ(API 2)
|
||||
*/
|
||||
class RemPaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* REM支払案内リンク生成
|
||||
*
|
||||
* POST /api/newwipe/rem/link
|
||||
*/
|
||||
public function createLink(
|
||||
RemLinkRequest $request,
|
||||
WellnetSoapService $soapService,
|
||||
PaymentLinkBuilder $linkBuilder
|
||||
): JsonResponse {
|
||||
$validated = $request->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);
|
||||
}
|
||||
}
|
||||
44
app/Http/Middleware/WellnetIpWhitelist.php
Normal file
44
app/Http/Middleware/WellnetIpWhitelist.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Wellnet Callback用IP制限ミドルウェア
|
||||
*/
|
||||
class WellnetIpWhitelist
|
||||
{
|
||||
/**
|
||||
* IPアドレス制限処理
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Closure $next
|
||||
* @return Response
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$allowedIps = config('wellnet.callback.allowed_ips', []);
|
||||
|
||||
// 白名単が空の場合は開発のみスキップ、商用は拒否
|
||||
if (empty($allowedIps)) {
|
||||
if (app()->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);
|
||||
}
|
||||
}
|
||||
106
app/Http/Requests/Api/CreditLinkRequest.php
Normal file
106
app/Http/Requests/Api/CreditLinkRequest.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* クレジット支払リンク生成リクエストバリデーション
|
||||
*/
|
||||
class CreditLinkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バリデーションルール
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'SyunoRecvNum' => ['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日以内で指定してください。');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
103
app/Http/Requests/Api/PaymentUpdateRequest.php
Normal file
103
app/Http/Requests/Api/PaymentUpdateRequest.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* 決済情報更新リクエストバリデーション
|
||||
*/
|
||||
class PaymentUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バリデーションルール
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'SyunoRecvNum' => ['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日以内で指定してください。');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Api/RefundRequest.php
Normal file
42
app/Http/Requests/Api/RefundRequest.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* 返金リクエストバリデーション
|
||||
*/
|
||||
class RefundRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バリデーションルール
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'SyunoRecvNum' => ['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));
|
||||
}
|
||||
}
|
||||
106
app/Http/Requests/Api/RemLinkRequest.php
Normal file
106
app/Http/Requests/Api/RemLinkRequest.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* REM支払案内リンク生成リクエストバリデーション
|
||||
*/
|
||||
class RemLinkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バリデーションルール
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'SyunoRecvNum' => ['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日以内で指定してください。');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
69
app/Http/Requests/Api/SubscriptionChargeRequest.php
Normal file
69
app/Http/Requests/Api/SubscriptionChargeRequest.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* 継続課金請求リクエストバリデーション
|
||||
*/
|
||||
class SubscriptionChargeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* バリデーションルール
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'member_id' => ['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の日付が不正です。');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
52
app/Models/CreditPaymentLog.php
Normal file
52
app/Models/CreditPaymentLog.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 継続課金請求履歴モデル
|
||||
*/
|
||||
class CreditPaymentLog extends Model
|
||||
{
|
||||
protected $table = 'credit_payment_log';
|
||||
protected $primaryKey = 'credit_payment_log_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'credit_subscription_id',
|
||||
'contract_id',
|
||||
'user_id',
|
||||
'request_amount',
|
||||
'execute_date',
|
||||
'request_at',
|
||||
'response_result',
|
||||
'response_code',
|
||||
'response_message',
|
||||
'payment_number',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'execute_date' => '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');
|
||||
}
|
||||
}
|
||||
38
app/Models/CreditSubscription.php
Normal file
38
app/Models/CreditSubscription.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 継続課金会員登録モデル
|
||||
*/
|
||||
class CreditSubscription extends Model
|
||||
{
|
||||
protected $table = 'credit_subscription';
|
||||
protected $primaryKey = 'credit_subscription_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'credit_member_id',
|
||||
'credit_card_seq',
|
||||
'subscription_status',
|
||||
'registered_at',
|
||||
'operator_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'registered_at' => 'datetime',
|
||||
'subscription_status' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* 利用者リレーション
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id', 'user_id');
|
||||
}
|
||||
}
|
||||
46
app/Models/PaymentTransaction.php
Normal file
46
app/Models/PaymentTransaction.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* 決済トランザクションモデル
|
||||
*/
|
||||
class PaymentTransaction extends Model
|
||||
{
|
||||
protected $table = 'payment_transaction';
|
||||
protected $primaryKey = 'payment_transaction_id';
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'syuno_recv_num',
|
||||
'payment_type',
|
||||
'status',
|
||||
'amount',
|
||||
'pay_limit',
|
||||
'kessai_no',
|
||||
'payment_number',
|
||||
'name_kanji',
|
||||
'name_kana',
|
||||
'tel',
|
||||
'subscription_flg',
|
||||
'paid_datetime',
|
||||
'store_code',
|
||||
'cvs_code',
|
||||
'refund_amount',
|
||||
'refund_status',
|
||||
'refund_id',
|
||||
'free_area',
|
||||
'wellnet_response',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'free_area' => 'array',
|
||||
'paid_datetime' => 'datetime',
|
||||
'pay_limit' => 'datetime',
|
||||
'amount' => 'integer',
|
||||
'subscription_flg' => 'integer',
|
||||
'refund_amount' => 'integer',
|
||||
];
|
||||
}
|
||||
34
app/Services/Wellnet/PaymentLinkBuilder.php
Normal file
34
app/Services/Wellnet/PaymentLinkBuilder.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wellnet;
|
||||
|
||||
/**
|
||||
* Wellnet決済リンク生成サービス
|
||||
*/
|
||||
class PaymentLinkBuilder
|
||||
{
|
||||
/**
|
||||
* クレジット決済画面URL生成(WPS/JLPcaf)
|
||||
*
|
||||
* @param string $kessaiNo 暗号化オンライン決済番号(KKessaiNo)
|
||||
* @return string クレジット決済画面URL
|
||||
*/
|
||||
public function buildCreditUrl(string $kessaiNo): string
|
||||
{
|
||||
$baseUrl = config('wellnet.wps.base_url');
|
||||
return $baseUrl . '/CafJLP/JLPcaf?GUID=' . urlencode($kessaiNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* REM支払案内画面URL生成(JLPcon)
|
||||
*
|
||||
* @param string $kessaiNo 暗号化オンライン決済番号(KKessaiNo)
|
||||
* @return string 支払案内画面URL
|
||||
*/
|
||||
public function buildRemUrl(string $kessaiNo): string
|
||||
{
|
||||
$baseUrl = config('wellnet.jlp.base_url');
|
||||
// api.pdf のサンプルURLに合わせて /JLPCT/JLPcon を利用
|
||||
return $baseUrl . '/JLPCT/JLPcon?GUID=' . urlencode($kessaiNo);
|
||||
}
|
||||
}
|
||||
172
app/Services/Wellnet/WellnetCreditService.php
Normal file
172
app/Services/Wellnet/WellnetCreditService.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wellnet;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wellnet クレジットAPIサービス(CAFIS外部インターフェース)
|
||||
*/
|
||||
class WellnetCreditService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $companyCode;
|
||||
private string $authKey;
|
||||
private int $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
218
app/Services/Wellnet/WellnetSoapService.php
Normal file
218
app/Services/Wellnet/WellnetSoapService.php
Normal file
@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wellnet;
|
||||
|
||||
use App\Exceptions\WellnetSoapException;
|
||||
use SoapClient;
|
||||
use SoapHeader;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wellnet SOAP通信サービス(YoyakuSyunoBarCode)
|
||||
*/
|
||||
class WellnetSoapService
|
||||
{
|
||||
private string $wsdlPath;
|
||||
private string $endpoint;
|
||||
private string $namespace;
|
||||
private string $encoding;
|
||||
private string $userId;
|
||||
private string $password;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
118
config/wellnet.php
Normal file
118
config/wellnet.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Wellnet 決済システム接続設定
|
||||
*
|
||||
* テスト環境と本番環境の切替は WELLNET_ENV で行う。
|
||||
*/
|
||||
|
||||
$env = env('WELLNET_ENV', 'test');
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 環境設定
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'env' => $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カード決済',
|
||||
],
|
||||
|
||||
];
|
||||
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 決済トランザクションテーブル作成
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('payment_transaction', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 継続課金会員登録テーブル作成
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('credit_subscription', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 継続課金請求履歴テーブル作成
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('credit_payment_log', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user