load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use app\miniapi\auth\Principal; use app\miniapi\exception\ApiException; use app\miniapi\model\MiniUser; use app\miniapi\model\MiniSession; use app\miniapi\model\MiniGrant; use app\miniapi\model\MiniCommand; use app\miniapi\model\MiniAudit; use app\miniapi\model\MiniCheckout; use app\miniapi\model\MiniEnterpriseBalance; use app\miniapi\model\MiniBalanceLedger; use app\miniapi\service\Access; use app\miniapi\service\AuthService; use app\miniapi\service\CheckoutService; use app\miniapi\service\IdentityManagement; use plugin\saiadmin\app\model\water\WaterEnterprise; use plugin\saiadmin\app\model\water\WaterSku; use plugin\saiadmin\app\model\water\WaterPackage; use plugin\saiadmin\app\model\water\WaterAccount; use plugin\saiadmin\app\model\water\WaterOrder; use plugin\saiadmin\app\model\water\WaterLedger; use plugin\saiadmin\app\service\WaterBusiness; use plugin\saiadmin\app\service\finance\ReceiptSources; $checks = 0; function check(bool $ok, string $message): void { global $checks; ++$checks; if (!$ok) { throw new RuntimeException($message); } } function reject(callable $callback, int $code): void { try { $callback(); } catch (ApiException $error) { check($error->getCode() === $code, 'expected rejection ' . $code . ', got ' . $error->getCode()); return; } throw new RuntimeException('Expected rejection ' . $code); } $models = [MiniUser::class, MiniSession::class, MiniGrant::class, MiniCommand::class, MiniAudit::class, MiniCheckout::class, MiniEnterpriseBalance::class, MiniBalanceLedger::class, WaterEnterprise::class, WaterSku::class, WaterPackage::class, WaterAccount::class, WaterOrder::class, WaterLedger::class]; $before = []; foreach ($models as $model) { $before[$model] = $model::query()->count(); } $connection = (new MiniUser())->getConnection(); $connection->beginTransaction(); $tag = 'SNAP-' . bin2hex(random_bytes(4)); try { $business = new WaterBusiness(); $enterprise = $business->save('enterprise', ['name' => $tag, 'contact' => '测试', 'phone' => '13812345678', 'address' => '回滚地址'], 1)['id']; $sku = $business->save('sku', ['name' => $tag, 'liters' => 18.9], 1)['id']; $sku2 = $business->save('sku', ['name' => $tag . '-other', 'liters' => 12], 1)['id']; $session = (new AuthService())->issue(['appid' => $tag, 'openid' => 'snapshot-user']); $actor = new Principal((string) $session['user_id']); (new IdentityManagement())->grant(1, ['user_id' => (int) $actor->userId, 'role' => 'enterprise_admin', 'resource_id' => $enterprise]); MiniEnterpriseBalance::query()->create(['enterprise_id' => $enterprise, 'balance_cents' => 1000000, 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s')]); $service = new CheckoutService(new Access($actor)); $termsPackage = $business->save('package', ['name' => $tag . '-terms', 'sku_id' => $sku, 'buckets' => 30, 'price_cents' => 69950], 1)['id']; $termsItems = ['items' => [['package_id' => $termsPackage, 'quantity' => 1]]]; $termsQuote = $service->quote($enterprise, $termsItems); WaterPackage::query()->where('id', $termsPackage)->update(['buckets' => 60, 'sku_id' => $sku2]); reject(fn () => $service->create($enterprise, $termsItems + ['expected_amount_cents' => 69950, 'quote_revision' => $termsQuote['quote_revision'], 'agreement_accepted' => true, 'request_key' => $tag . '-stale-terms']), 40904); check(MiniCheckout::query()->where('enterprise_id', $enterprise)->count() === 0, 'same-price SKU/bucket changes require reconfirmation'); $number = 0; $make = function () use (&$number, $business, $service, $enterprise, $sku, $tag) { $number++; $package = $business->save('package', ['name' => $tag . '-' . $number, 'sku_id' => $sku, 'buckets' => 30, 'price_cents' => 69950], 1)['id']; $order = $service->create($enterprise, ['items' => [['package_id' => $package, 'quantity' => 2]], 'expected_amount_cents' => 139900, 'agreement_accepted' => true, 'request_key' => $tag . '-create-' . $number]); return [$package, $order]; }; $pay = fn ($order, $suffix) => $service->pay($enterprise, $order['id'], ['payment_method' => 'balance', 'request_key' => $tag . '-pay-' . $suffix]); foreach (['edited', 'disabled', 'deleted'] as $scenario) { [$package, $order] = $make(); if ($scenario === 'edited') { WaterPackage::query()->where('id', $package)->update(['name' => 'changed', 'sku_id' => $sku2, 'price_cents' => 79900, 'buckets' => 60]); } if ($scenario === 'disabled') { WaterPackage::query()->where('id', $package)->update(['status' => 2]); } if ($scenario === 'deleted') { $business->deletePackage(['id' => $package], 1); } $balanceBefore = (int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents'); $rightsBefore = (int) WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement'); check($pay($order, $scenario)['status'] === 'paid', $scenario . ' pending checkout pays'); $receipts = WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->get(); check($receipts->count() === 2, 'quantity produces two receipts'); check((int) $receipts->sum('amount_cents') === 139900, 'receipt total equals snapshot debit'); check((int) $receipts->sum('buckets') === 60, 'snapshot buckets issued'); check($receipts->every(fn ($row) => (int) $row->sku_id === $sku && $row->package_name === $order['items'][0]['name']), 'original SKU and name retained'); check($receipts->every(fn ($row) => (int) $row->created_by === 0), 'mini user IDs do not impersonate admin IDs'); check((int) WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rightsBefore + 60, 'account credits equal receipt buckets'); check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === $balanceBefore - 139900, 'snapshot amount deducted'); check((int) MiniBalanceLedger::query()->where('checkout_id', $order['id'])->value('amount_cents') === 139900, 'debit ledger reconciles'); $sources = ReceiptSources::label($receipts->toArray()); check(count($sources) === 2 && array_unique(array_values($sources)) === ['企业余额购水'], 'finance resolves true balance provenance'); check(WaterLedger::query()->where('reference', $receipts[0]->reference)->value('note') === '企业余额支付,按订单快照登记套餐权益', 'correct receipt audit note'); check($pay($order, $scenario)['replayed'] === true, 'retry is idempotent'); reject(fn () => $pay($order, $scenario . '-new-key'), 40902); check(WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->count() === 2, 'no duplicate receipts'); } [$package, $order] = $make(); foreach (['sku', 'expired', 'cancelled', 'corrupt', 'insufficient'] as $scenario) { WaterSku::query()->where('id', $sku)->update(['status' => $scenario === 'sku' ? 2 : 1]); MiniCheckout::query()->where('id', $order['id'])->update(['status' => $scenario === 'cancelled' ? 'cancelled' : 'pending', 'expires_at' => date('Y-m-d H:i:s', time() + ($scenario === 'expired' ? -1 : 1800)), 'amount_cents' => $scenario === 'corrupt' ? 139901 : 139900]); $balance = $scenario === 'insufficient' ? 1 : 1000000; MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->update(['balance_cents' => $balance]); $rights = WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement'); reject(fn () => $pay($order, $scenario), ['sku' => 40902, 'expired' => 40906, 'cancelled' => 40902, 'corrupt' => 40902, 'insufficient' => 40905][$scenario]); check(WaterOrder::query()->where('reference', 'like', $order['code'] . '-%')->count() === 0, 'failed payment issues no receipts'); check(MiniBalanceLedger::query()->where('checkout_id', $order['id'])->count() === 0, 'failed payment writes no debit'); check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === $balance, 'failed payment preserves balance'); check(WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rights, 'failed payment preserves rights'); } // Force a failure after the first receipt has been issued: all earlier writes must roll back. MiniCheckout::query()->where('id', $order['id'])->update(['status' => 'pending', 'amount_cents' => 139900]); MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->update(['balance_cents' => 1000000]); $collision = WaterOrder::query()->create(['enterprise_id' => $enterprise, 'package_id' => $package, 'sku_id' => $sku2, 'buckets' => 1, 'amount_cents' => 1, 'package_name' => 'collision', 'reference' => $order['code'] . '-' . $package . '-2', 'created_by' => 1, 'created_at' => date('Y-m-d H:i:s')]); $rights = WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement'); reject(fn () => $pay($order, 'collision'), 40902); check(WaterOrder::query()->where('reference', $order['code'] . '-' . $package . '-1')->count() === 0, 'earlier receipt rolled back on late failure'); check(WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->value('entitlement') === $rights, 'earlier rights rolled back'); check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === 1000000, 'late failure preserves balance'); check(MiniCheckout::query()->find($order['id'])->status === 'pending', 'late failure preserves pending state'); check(ReceiptSources::label([$collision->toArray()]) === [], 'unpaid checkout prefix is not balance provenance'); } finally { $connection->rollBack(); } foreach ($before as $model => $count) { check($model::query()->count() === $count, 'fixtures restored: ' . $model); } echo "PASS {$checks} checkout snapshot checks; all fixture writes rolled back.\n";