支払いAPI実装
All checks were successful
Deploy api / deploy (push) Successful in 22s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-23 20:02:25 +09:00
parent 0b4acd7475
commit f139a3f608
27 changed files with 2678 additions and 0 deletions

View 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;
}
}

View 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;
}
}

View 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];
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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];
}
}

View 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);
}
}

View 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);
}
}

View 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日以内で指定してください。');
}
});
}
}

View 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日以内で指定してください。');
}
});
}
}

View 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));
}
}

View 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日以内で指定してください。');
}
});
}
}

View 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の日付が不正です。');
}
});
}
}

View 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');
}
}

View 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');
}
}

View 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',
];
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View File

@ -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
View 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'),
],
/*
|--------------------------------------------------------------------------
| クレジットAPICAFIS外部インターフェース
|--------------------------------------------------------------------------
*/
'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カード決済',
],
];

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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 - 決済APIAPI 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']);
});

View File

@ -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();