积分与余额账务系统硬切重构

This commit is contained in:
2026-08-24 15:59:11 +08:00
parent ca12226b39
commit 5cae5b5c93
159 changed files with 7275 additions and 4718 deletions
+76
View File
@@ -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);
}
}
+115
View File
@@ -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());
}
}
+18
View File
@@ -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']);
}
}
+28
View File
@@ -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);
}
}
+61
View File
@@ -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';