积分与余额账务系统硬切重构
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use AKSRT\Ledger\AssetAmount;
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AssetAmountTest extends TestCase {
|
||||
#[DataProvider('validBalanceProvider')]
|
||||
public function test_balance_parses_exact_decimal_strings(string $input, int $minor, string $decimal): void {
|
||||
$amount = AssetAmount::balance($input);
|
||||
self::assertSame(AssetType::BALANCE, $amount->asset());
|
||||
self::assertSame($minor, $amount->minor());
|
||||
self::assertSame($decimal, $amount->decimal());
|
||||
}
|
||||
|
||||
public static function validBalanceProvider(): array {
|
||||
return [
|
||||
'zero' => ['0', 0, '0.00'],
|
||||
'one decimal' => ['12.3', 1230, '12.30'],
|
||||
'two decimals' => ['12.34', 1234, '12.34'],
|
||||
'integer' => ['999', 99900, '999.00'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidBalanceProvider')]
|
||||
public function test_balance_rejects_non_canonical_input(string $input): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
AssetAmount::balance($input);
|
||||
}
|
||||
|
||||
public static function invalidBalanceProvider(): array {
|
||||
return [
|
||||
'negative' => ['-1.00'],
|
||||
'float precision' => ['1.001'],
|
||||
'scientific notation' => ['1e2'],
|
||||
'leading zero' => ['01.00'],
|
||||
'leading decimal' => ['.50'],
|
||||
'trailing decimal' => ['1.'],
|
||||
'plus sign' => ['+1.00'],
|
||||
'comma' => ['1,00'],
|
||||
'empty' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_balance_rejects_integer_overflow(): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
AssetAmount::balance((string) PHP_INT_MAX . '.99');
|
||||
}
|
||||
|
||||
public function test_points_are_integer_minor_units(): void {
|
||||
self::assertSame(37, AssetAmount::points(37)->minor());
|
||||
self::assertSame('37', AssetAmount::points(37)->decimal());
|
||||
}
|
||||
|
||||
public function test_negative_points_are_rejected(): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
AssetAmount::points(-1);
|
||||
}
|
||||
|
||||
public function test_balance_minor_helpers_do_not_use_floats(): void {
|
||||
self::assertSame(1234, AssetAmount::balance_minor('12.34'));
|
||||
self::assertSame('12.34', AssetAmount::decimal_from_minor(1234));
|
||||
}
|
||||
|
||||
public function test_percentage_rounds_half_up_in_minor_units(): void {
|
||||
self::assertSame(266, AssetAmount::percentage(333, 80));
|
||||
self::assertSame(1, AssetAmount::percentage(1, 50));
|
||||
self::assertSame(0, AssetAmount::percentage(1, 49));
|
||||
}
|
||||
|
||||
public function test_percentage_rejects_out_of_range_values(): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
AssetAmount::percentage(100, 101);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerCommand;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LedgerCommandTest extends TestCase {
|
||||
public function test_transfer_splits_principal_fee_and_net_receipt(): void {
|
||||
$command = LedgerCommand::transfer(
|
||||
'client-transfer:1:550e8400-e29b-41d4-a716-446655440000',
|
||||
'transfer',
|
||||
10,
|
||||
20,
|
||||
AssetType::BALANCE,
|
||||
1000,
|
||||
125
|
||||
);
|
||||
|
||||
self::assertSame([-875, -125, 875], array_column($command->entries, 'delta_minor'));
|
||||
self::assertSame(['transfer_out', 'fee', 'transfer_in'], array_column($command->entries, 'entry_type'));
|
||||
self::assertSame(1000, $command->metadata['gross_minor']);
|
||||
self::assertSame(125, $command->metadata['fee_minor']);
|
||||
self::assertSame(875, $command->metadata['net_minor']);
|
||||
self::assertSame(-125, array_sum(array_column($command->entries, 'delta_minor')));
|
||||
}
|
||||
|
||||
public function test_transfer_rejects_net_below_one_minor_unit(): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
LedgerCommand::transfer('transfer:key', 'transfer', 1, 2, AssetType::POINTS, 10, 10);
|
||||
}
|
||||
|
||||
public function test_transfer_rejects_negative_fee_before_arithmetic(): void {
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
LedgerCommand::transfer('transfer:key', 'transfer', 1, 2, AssetType::POINTS, PHP_INT_MAX, -1);
|
||||
}
|
||||
|
||||
public function test_validation_returns_400_for_bad_operation_key(): void {
|
||||
$command = new LedgerCommand('bad key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => 1,
|
||||
'entry_type' => 'credit',
|
||||
]]);
|
||||
$error = $command->validate();
|
||||
self::assertInstanceOf(WP_Error::class, $error);
|
||||
self::assertSame('aksrt_ledger_operation_key', $error->get_error_code());
|
||||
self::assertSame(400, $error->get_error_data()['status']);
|
||||
}
|
||||
|
||||
public function test_payload_hash_is_stable_for_metadata_key_order(): void {
|
||||
$entries = [['user_id' => 1, 'asset' => 'points', 'delta_minor' => 2, 'entry_type' => 'credit']];
|
||||
$first = new LedgerCommand('stable:key', 'credit', $entries, '', '', 0, ['b' => 2, 'a' => 1]);
|
||||
$second = new LedgerCommand('stable:key', 'credit', $entries, '', '', 0, ['a' => 1, 'b' => 2]);
|
||||
self::assertSame($first->payload_hash(), $second->payload_hash());
|
||||
}
|
||||
|
||||
public function test_payload_hash_changes_with_business_payload(): void {
|
||||
$first = new LedgerCommand('stable:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => 2,
|
||||
'entry_type' => 'credit',
|
||||
]]);
|
||||
$second = new LedgerCommand('stable:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => 3,
|
||||
'entry_type' => 'credit',
|
||||
]]);
|
||||
self::assertNotSame($first->payload_hash(), $second->payload_hash());
|
||||
}
|
||||
|
||||
public function test_validation_rejects_empty_entries_and_invalid_asset(): void {
|
||||
$empty = new LedgerCommand('empty:key', 'credit', []);
|
||||
self::assertSame('aksrt_ledger_entries', $empty->validate()->get_error_code());
|
||||
|
||||
$invalid = new LedgerCommand('asset:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'cash',
|
||||
'delta_minor' => 1,
|
||||
'entry_type' => 'credit',
|
||||
]]);
|
||||
self::assertSame('aksrt_ledger_asset', $invalid->validate()->get_error_code());
|
||||
}
|
||||
|
||||
public function test_validation_requires_integer_non_zero_entry_values(): void {
|
||||
foreach (['1', 0, PHP_INT_MIN] as $delta) {
|
||||
$command = new LedgerCommand('entry:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => $delta,
|
||||
'entry_type' => 'credit',
|
||||
]]);
|
||||
self::assertSame('aksrt_ledger_entry', $command->validate()->get_error_code());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_validation_rejects_negative_actor_and_invalid_entry_type(): void {
|
||||
$actor = new LedgerCommand('actor:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => 1,
|
||||
'entry_type' => 'credit',
|
||||
]], '', '', -1);
|
||||
self::assertSame('aksrt_ledger_actor', $actor->validate()->get_error_code());
|
||||
|
||||
$type = new LedgerCommand('type:key', 'credit', [[
|
||||
'user_id' => 1,
|
||||
'asset' => 'points',
|
||||
'delta_minor' => 1,
|
||||
'entry_type' => 'Bad Type',
|
||||
]]);
|
||||
self::assertSame('aksrt_ledger_entry', $type->validate()->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use AKSRT\Ledger\AssetType;
|
||||
use AKSRT\Ledger\LedgerResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LedgerResultTest extends TestCase {
|
||||
public function test_balance_reads_integer_minor_units(): void {
|
||||
$result = new LedgerResult(17, true, [], [
|
||||
4 => ['points' => 25, 'balance' => 1234],
|
||||
]);
|
||||
self::assertSame(25, $result->balance(4, AssetType::POINTS));
|
||||
self::assertSame(1234, $result->balance(4, AssetType::BALANCE));
|
||||
self::assertSame(0, $result->balance(99, AssetType::BALANCE));
|
||||
self::assertSame(17, $result->to_array()['transaction_id']);
|
||||
self::assertTrue($result->to_array()['replayed']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use AKSRT\Ledger\OperationKey;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class OperationKeyTest extends TestCase {
|
||||
public function test_request_key_requires_uuid_header(): void {
|
||||
$error = OperationKey::from_request(new WP_REST_Request(), 'transfer', 9);
|
||||
self::assertInstanceOf(WP_Error::class, $error);
|
||||
self::assertSame('aksrt_idempotency_key', $error->get_error_code());
|
||||
self::assertSame(400, $error->get_error_data()['status']);
|
||||
}
|
||||
|
||||
public function test_request_key_is_scoped_to_actor_and_operation(): void {
|
||||
$request = new WP_REST_Request(['Idempotency-Key' => '550E8400-E29B-41D4-A716-446655440000']);
|
||||
self::assertSame(
|
||||
'client-transfer:9:550e8400-e29b-41d4-a716-446655440000',
|
||||
OperationKey::from_request($request, 'transfer', 9)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_deterministic_key_is_stable_and_length_bounded(): void {
|
||||
$key = OperationKey::deterministic('Revenue Settle', [42, str_repeat('A', 300)]);
|
||||
self::assertSame($key, OperationKey::deterministic('Revenue Settle', [42, str_repeat('A', 300)]));
|
||||
self::assertLessThanOrEqual(191, strlen($key));
|
||||
self::assertMatchesRegularExpression('/^[a-z0-9:._-]+$/', $key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
if (!function_exists('__')) {
|
||||
function __(string $text, string $domain = ''): string {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
if (!function_exists('sanitize_key')) {
|
||||
function sanitize_key(string $key): string {
|
||||
return strtolower((string) preg_replace('/[^a-z0-9_\-]/', '', $key));
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_json_encode')) {
|
||||
function wp_json_encode($value, int $flags = 0) {
|
||||
return json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
if (!function_exists('is_wp_error')) {
|
||||
function is_wp_error($value): bool {
|
||||
return $value instanceof WP_Error;
|
||||
}
|
||||
}
|
||||
if (!function_exists('wp_is_uuid')) {
|
||||
function wp_is_uuid($uuid, $version = null): bool {
|
||||
$pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-([0-9a-f])[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i';
|
||||
if (!is_string($uuid) || !preg_match($pattern, $uuid, $matches)) {
|
||||
return false;
|
||||
}
|
||||
return $version === null || strtolower($matches[1]) === strtolower((string) $version);
|
||||
}
|
||||
}
|
||||
if (!class_exists('WP_Error')) {
|
||||
class WP_Error {
|
||||
public function __construct(private string $code, private string $message = '', private $data = null) {}
|
||||
public function get_error_code(): string { return $this->code; }
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
public function get_error_data() { return $this->data; }
|
||||
}
|
||||
}
|
||||
if (!class_exists('WP_REST_Request')) {
|
||||
class WP_REST_Request {
|
||||
/** @param array<string,string> $headers */
|
||||
public function __construct(private array $headers = []) {}
|
||||
public function get_header(string $name): string {
|
||||
foreach ($this->headers as $key => $value) {
|
||||
if (strcasecmp($key, $name) === 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetType.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/AssetAmount.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerCommand.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/LedgerResult.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Ledger/OperationKey.php';
|
||||
Reference in New Issue
Block a user