Skip to content

Commit e22cb7f

Browse files
nanasessclaude
andcommitted
fix(agent-commerce): capture の戻り値と再試行可否を本体の契約に合わせる
EC-CUBE/ec-cube#7032 のマージで決済ハンドラの契約が 2 点明確になったため追随する。 - **capture の戻り値は COMPLETED か FAILED のみ**。従来は authorize と写像を共用しており、 ゲートウェイが REQUIRES_CAPTURE / REQUIRES_ACTION / PROCESSING を返すと AUTHORIZED / REQUIRES_ACTION / PENDING をそのまま返していた。本体はこれらを失敗として扱うが、 errorCode / errorMessage が無いぶん理由を伝えられない。capture 専用の写像を分け、 非終端ステータスはログに残したうえで capture_unexpected_status の失敗へ畳む。 - **与信が残り再 authorize できない場合の capture 失敗は retryable=false**。本体には capture 単独の再実行入口が無く、ready からの再 complete は新規 authorize から始まる (保持した PSP 参照は渡らない)。ACP は入口が Shared Payment Token の償還でワンショット のため、ready へ戻しても再試行は必ず失敗し与信だけが残る。プロトコル別の captureFailureIsRetryable() で分岐し、ACP=false / UCP=true とする。UCP はエージェントが complete のたびに credential を送り直し exchange をやり直せるため再試行が成立する。 ゲートウェイが不可逆と判断した失敗 (金額不一致等) は、再 authorize できるプロトコルでも 再試行させないよう AND で畳む。capture の例外経路では metadata も引き継ぐようにした (取消・照会に要る)。 紛らわしかった toOutcome() は toAuthorizeOutcome() へ改名し、capture からの流用を防ぐ。 検証: PHPUnit 86 tests / 177 assertions / 0 failures、PHPStan level 6 No errors。 追加した 6 テストは修正前に落ちることを確認済み。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 59ebb33 commit e22cb7f

5 files changed

Lines changed: 161 additions & 12 deletions

File tree

Service/AgentCommerce/AbstractAgentCardHandler.php

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ public function __construct(
5151
*/
5252
abstract protected function protocolId(): int;
5353

54+
/**
55+
* capture 失敗を再試行可能 (ready へ戻す) として返してよいか.
56+
*
57+
* コアは **capture 単独の再実行入口を持たない**。ready からの再 complete は新規 {@link authorize()}
58+
* から始まり、保持した PSP 参照はハンドラへ渡らない
59+
* ({@link \Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerInterface::capture()})。
60+
* したがって「同じ $paymentData から instrument を作り直せるか」がそのまま再試行可否になる。
61+
*
62+
* 作り直せないのに true を返すと、再試行は必ず失敗したうえ与信だけが PSP 側に残る。
63+
*/
64+
abstract protected function captureFailureIsRetryable(): bool;
65+
5466
/**
5567
* complete リクエストの中立支払データを、ゲートウェイへ渡す instrument へ整形する.
5668
*
@@ -120,7 +132,7 @@ public function authorize(Order $order, array $paymentData, array $paymentRefere
120132
return PaymentOutcome::failed('payment_gateway_error', 'The payment gateway could not be reached.', true);
121133
}
122134

123-
return $this->toOutcome($result);
135+
return $this->toAuthorizeOutcome($result);
124136
}
125137

126138
/**
@@ -142,15 +154,20 @@ public function capture(Order $order, array $paymentData, PaymentOutcome $author
142154
$this->context($order),
143155
);
144156
} catch (\Throwable $e) {
145-
// capture 中の失敗は authorize と扱いが異なる。コアは在庫を rollback する一方、PSP 側の与信は
146-
// 残るため、retryable=true でセッションを ready に戻し、同一取引の再 capture / 取消を可能にする
147-
// (canceled にすると与信が残ったまま不可逆になる)。
157+
// 与信は PSP 側に残るため、取消・照会できるよう取引識別子と metadata を引き継ぐ。
158+
// 再試行可否は「新規 authorize をやり直せるか」で決まる ({@link captureFailureIsRetryable()})。
148159
$this->logger->error('Agent payment capture failed.', ['exception' => $e, 'order_no' => $order->getOrderNo(), 'transaction_id' => $transactionId]);
149160

150-
return PaymentOutcome::failed('payment_capture_error', 'The payment gateway could not be reached.', true, $transactionId);
161+
return PaymentOutcome::failed(
162+
'payment_capture_error',
163+
'The payment gateway could not be reached.',
164+
$this->captureFailureIsRetryable(),
165+
$transactionId,
166+
$authorization->metadata,
167+
);
151168
}
152169

153-
return $this->toOutcome($result);
170+
return $this->toCaptureOutcome($result, $order);
154171
}
155172

156173
/**
@@ -173,12 +190,52 @@ private function context(Order $order): array
173190
}
174191

175192
/**
176-
* ゲートウェイ結果をコアの {@link PaymentOutcome} へ写像する.
193+
* capture のゲートウェイ結果をコアの {@link PaymentOutcome} へ写像する.
194+
*
195+
* **コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」**。中間状態を返してもコアは
196+
* 失敗として扱い在庫を回収するため、authorize 用の {@link toAuthorizeOutcome()} は流用せず
197+
* ここで終端 2 値へ畳む。REQUIRES_CAPTURE / REQUIRES_ACTION / PROCESSING が返るのは
198+
* ゲートウェイ実装の誤りなので、ログに残したうえで失敗にする (fail-closed)。
199+
*/
200+
private function toCaptureOutcome(GatewayResult $result, Order $order): PaymentOutcome
201+
{
202+
if ($result->status === GatewayStatus::SUCCEEDED) {
203+
return PaymentOutcome::completed($result->transactionId, $result->metadata);
204+
}
205+
206+
if ($result->status === GatewayStatus::FAILED) {
207+
return PaymentOutcome::failed(
208+
$result->errorCode ?? 'capture_failed',
209+
$result->errorMessage ?? '',
210+
// ゲートウェイが不可逆と判断した失敗 (金額不一致等) は、再 authorize できても再試行させない。
211+
$result->retryable && $this->captureFailureIsRetryable(),
212+
$result->transactionId,
213+
$result->metadata,
214+
);
215+
}
216+
217+
$this->logger->error('The payment gateway returned a non-terminal status from capture.', [
218+
'order_no' => $order->getOrderNo(),
219+
'status' => $result->status->value,
220+
'transaction_id' => $result->transactionId,
221+
]);
222+
223+
return PaymentOutcome::failed(
224+
'capture_unexpected_status',
225+
'The payment could not be captured.',
226+
$this->captureFailureIsRetryable(),
227+
$result->transactionId,
228+
$result->metadata,
229+
);
230+
}
231+
232+
/**
233+
* authorize のゲートウェイ結果をコアの {@link PaymentOutcome} へ写像する.
177234
*
178235
* 与信のみ (REQUIRES_CAPTURE) と売上確定済 (SUCCEEDED) を区別する点が要。潰して COMPLETED に
179236
* すると、auto-capture 型 PSP へ差し替えたときにコアが capture を二重発行する。
180237
*/
181-
private function toOutcome(GatewayResult $result): PaymentOutcome
238+
private function toAuthorizeOutcome(GatewayResult $result): PaymentOutcome
182239
{
183240
return match ($result->status) {
184241
GatewayStatus::REQUIRES_CAPTURE => PaymentOutcome::authorized($result->transactionId, $result->metadata),

Service/AgentCommerce/Acp/AcpSampleCardHandler.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ protected function protocolId(): int
5353
return AgentProtocol::ACP;
5454
}
5555

56+
/**
57+
* ACP の capture 失敗は**再試行させない**.
58+
*
59+
* ready からの再 complete は新規 authorize から始まるが、その入口である
60+
* {@link redeemSharedPaymentToken()} は Shared Payment Token の償還であり、SPT はワンショットで
61+
* 2 度目が失敗する。再試行を許しても必ず失敗し、与信だけが PSP 側に残るため canceled にする
62+
* (与信の取消は PSP 側の運用に委ねる)。
63+
*/
64+
protected function captureFailureIsRetryable(): bool
65+
{
66+
return false;
67+
}
68+
5669
protected function toGatewayInstrument(array $paymentData): array
5770
{
5871
// SPT の償還はワンショットなので authorize からの 1 度だけ。capture は与信結果を使う

Service/AgentCommerce/Ucp/UcpSampleCardHandler.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ protected function protocolId(): int
5757
return AgentProtocol::UCP;
5858
}
5959

60+
/**
61+
* UCP の capture 失敗は**再試行を許す**.
62+
*
63+
* ready からの再 complete は新規 authorize から始まるが、UCP はエージェントが complete のたびに
64+
* payment.instruments[].credential を送り直し、controller が {@link exchangePaymentToken()} で
65+
* 交換をやり直す。つまり同じ入力から instrument を作り直せるため ready へ戻して再試行できる。
66+
*
67+
* ワンショットのクレデンシャルを扱う PSP へ差し替える場合は false を返すこと。
68+
*/
69+
protected function captureFailureIsRetryable(): bool
70+
{
71+
return true;
72+
}
73+
6074
protected function toGatewayInstrument(array $paymentData): array
6175
{
6276
// UCP は controller の resolvePaymentData() が exchangePaymentToken() 済みの中立データを渡す。

Tests/Service/AgentCommerce/Acp/AcpSampleCardHandlerTest.php

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Eccube\Service\AgentCommerce\MinorUnitConverter;
1818
use Eccube\Service\AgentCommerce\Payment\PaymentOutcome;
1919
use Eccube\Service\AgentCommerce\Payment\PaymentOutcomeStatus;
20+
use PHPUnit\Framework\Attributes\DataProvider;
2021
use Plugin\SamplePayment44\Service\AgentCommerce\Acp\AcpSampleCardHandler;
2122
use Plugin\SamplePayment44\Service\AgentCommerce\Exception\InvalidPaymentDataException;
2223
use Plugin\SamplePayment44\Service\AgentCommerce\Gateway\AgentPaymentGatewayInterface;
@@ -122,17 +123,65 @@ public function testGatewayExceptionOnAuthorizeIsMappedToRetryableFailure(): voi
122123
$this->assertStringNotContainsString('connection reset', $outcome->errorMessage ?? '', 'PSP の内部メッセージをエージェントへ露出しない');
123124
}
124125

125-
public function testGatewayExceptionOnCaptureKeepsTransactionReferenceAndStaysRetryable(): void
126+
public function testGatewayExceptionOnCaptureIsNotRetryableAndKeepsTransactionReference(): void
126127
{
127-
$spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_4'), new \RuntimeException('timeout'));
128+
$spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_4', ['gateway' => 'spy']), new \RuntimeException('timeout'));
128129
$order = $this->createOrder(AgentProtocol::ACP, CreditCard::class);
129130

130131
$authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']);
131132
$outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization);
132133

133134
$this->assertSame('payment_capture_error', $outcome->errorCode);
134-
$this->assertTrue($outcome->retryable, '与信は PSP 側に残るため canceled にせず ready へ戻す');
135+
// コアに capture 単独の再実行入口は無く、ready からの再試行は新規 authorize = SPT の再償還になる。
136+
// ワンショットのため必ず失敗するので、ready へ戻さず canceled にする。
137+
$this->assertFalse($outcome->retryable, 'SPT は再償還できないため capture 失敗を再試行させない');
135138
$this->assertSame('pi_4', $outcome->transactionId, '取消・照会のため取引識別子を残す');
139+
$this->assertSame(['gateway' => 'spy'], $outcome->metadata, '照会に必要な metadata も引き継ぐ');
140+
}
141+
142+
public function testGatewayCaptureFailureIsForcedNonRetryable(): void
143+
{
144+
// ゲートウェイが「再試行可」と言っても、ACP では再 authorize が成立しないため上書きする。
145+
$spy = new SpyAgentPaymentGateway(
146+
GatewayResult::requiresCapture('pi_6'),
147+
GatewayResult::failed('capture_failed', 'The capture was rejected.', true, 'pi_6'),
148+
);
149+
$order = $this->createOrder(AgentProtocol::ACP, CreditCard::class);
150+
151+
$authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']);
152+
$outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization);
153+
154+
$this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status);
155+
$this->assertSame('capture_failed', $outcome->errorCode);
156+
$this->assertFalse($outcome->retryable);
157+
}
158+
159+
#[DataProvider('nonTerminalCaptureResults')]
160+
public function testCaptureNeverReturnsNonTerminalOutcome(GatewayResult $captureResult): void
161+
{
162+
// コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」。中間状態を返すとコアは
163+
// 失敗として扱うが、errorCode / errorMessage が無いぶん理由を伝えられない。
164+
$spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_7'), $captureResult);
165+
$order = $this->createOrder(AgentProtocol::ACP, CreditCard::class);
166+
167+
$authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']);
168+
$outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization);
169+
170+
$this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status, 'capture は COMPLETED か FAILED しか返さない');
171+
$this->assertSame('capture_unexpected_status', $outcome->errorCode);
172+
$this->assertNotSame('', $outcome->errorMessage ?? '', '理由を伝えられるようメッセージを載せる');
173+
}
174+
175+
/**
176+
* capture が返してはならないゲートウェイ結果.
177+
*
178+
* @return \Iterator<string, array{GatewayResult}>
179+
*/
180+
public static function nonTerminalCaptureResults(): \Iterator
181+
{
182+
yield 'requires_capture (未 capture のまま)' => [GatewayResult::requiresCapture('pi_7')];
183+
yield 'requires_action (capture 中の追加認証)' => [GatewayResult::requiresAction(['type' => '3ds'], 'pi_7')];
184+
yield 'processing (非同期確定)' => [GatewayResult::processing('pi_7')];
136185
}
137186

138187
public function testRequiresActionCarriesActionDataMetadataAndReference(): void

Tests/Service/AgentCommerce/Ucp/UcpSampleCardHandlerTest.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,26 @@ public function testCaptureFailureIsRetryableAndKeepsReference(): void
139139
$captured = $handler->capture($order, [], $authorization);
140140
$this->assertSame(PaymentOutcomeStatus::FAILED, $captured->status, 'capture 失敗の分岐を検証できる規約を持つ');
141141
$this->assertSame('capture_failed', $captured->errorCode);
142-
$this->assertTrue($captured->retryable);
142+
// UCP はエージェントが complete のたびに credential を送り直すため、ready からの再試行で
143+
// exchange → authorize をやり直せる (ACP の SPT と非対称なのはここ)。
144+
$this->assertTrue($captured->retryable, 'credential を再送すれば新規 authorize からやり直せる');
143145
$this->assertSame($authorization->transactionId, $captured->transactionId);
144146
}
145147

148+
public function testCaptureNeverReturnsNonTerminalOutcome(): void
149+
{
150+
// コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」。
151+
$spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_u1'), GatewayResult::processing('pi_u1'));
152+
$order = $this->createOrder(AgentProtocol::UCP, CreditCard::class);
153+
154+
$authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']);
155+
$outcome = $this->handler($spy)->capture($order, [], $authorization);
156+
157+
$this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status);
158+
$this->assertSame('capture_unexpected_status', $outcome->errorCode);
159+
$this->assertTrue($outcome->retryable, 'UCP は再 authorize できるため契約違反でも ready へ戻す');
160+
}
161+
146162
private function handler(AgentPaymentGatewayInterface $gateway): UcpSampleCardHandler
147163
{
148164
return new UcpSampleCardHandler(new MinorUnitConverter(), $gateway, new NullLogger());

0 commit comments

Comments
 (0)