All checks were successful
Deploy api / deploy (push) Successful in 22s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
12 KiB
PHP
328 lines
12 KiB
PHP
<?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];
|
||
}
|
||
}
|