From 5d9b1d2f5a06cc749fc180d1df1a0b29924cf355 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Sun, 1 Nov 2026 13:35:36 +0330 Subject: [PATCH 1/3] Restructure service modules and resolve quality checks --- .dockerignore | 24 +- .env.example | 6 + .github/workflows/_database-security.yml | 2 +- .github/workflows/_publish.yml | 10 +- .gitignore | 6 + README.md | 301 ++++++-- ...(not RabbitMQ) for cross-service events.md | 58 -- docs/infrastructure-validation.md | 79 +- docs/infrastructure.md | 33 +- infrastructure/application.Dockerfile | 2 +- infrastructure/compose.operations.yaml | 8 +- .../callbacks/identity/afterMigrate.sql | 4 +- .../callbacks/identity/afterMigrateError.sql | 4 +- infrastructure/migrations.sha256 | 8 +- .../operations/tests/integration.yml | 2 +- infrastructure/postgresql/entrypoint.sh | 3 +- infrastructure/postgresql/import-identity.sql | 18 +- .../legacy/oracle/V1__collateral_schema.sql | 8 - .../Dockerfile | 0 .../README.md | 2 +- .../pom.xml | 22 +- .../CollateralServiceApplication.java | 0 .../CollateralApplicationService.java | 0 .../application/CollateralDataExportPort.java | 0 .../CollateralLiquidationPort.java | 0 .../application/CollateralRepository.java | 0 .../application/SubjectTokenVault.java | 0 .../collateral/domain/Collateral.java | 0 .../collateral/domain/CollateralStatus.java | 0 .../collateral/domain/PriceOracle.java | 0 .../payguard/collateral/domain/Valuation.java | 0 .../CollateralKafkaConfiguration.java | 0 .../src/main/resources/application.yaml | 4 - .../src/main/resources/banner.txt | 0 .../postgresql/V1__collateral_schema.sql | 0 .../CollateralServiceApplicationTests.java | 0 .../domain/CollateralDomainTest.java | 0 .../src/test/resources/application-test.yaml | 0 .../src/test/resources/postgres-test-init.sql | 0 .../pom.xml | 2 +- .../gateway/ApiGatewayApplication.java | 0 .../client/CollateralGatewayClient.java | 0 .../gateway/client/LoanGatewayClient.java | 0 .../gateway/client/WalletGatewayClient.java | 0 .../gateway/config/GatewayProperties.java | 0 .../config/GrpcClientConfiguration.java | 0 .../GatewaySecurityConfiguration.java | 0 .../gateway/web/CollateralController.java | 0 .../gateway/web/CorrelationIdFilter.java | 0 .../gateway/web/GatewayExceptionHandler.java | 0 .../amg/payguard/gateway/web/GatewayJson.java | 0 .../web/GatewayRequestContextFactory.java | 0 .../web/GatewaySerializationException.java | 0 .../payguard/gateway/web/LoanController.java | 0 .../gateway/web/WalletController.java | 0 .../payguard/gateway/web/WalletRequests.java | 0 .../src/main/resources/application.yaml | 0 .../src/main/resources/banner.txt | 0 .../gateway/ApiGatewayApplicationTests.java | 0 .../web/GatewayRequestContextFactoryTest.java | 0 payguard-identity-service/ARCHITECTURE.md | 712 ------------------ .../application/IssueOtpCommand.java | 13 - .../verification/application/IssuedOtp.java | 9 - .../application/OtpApplicationService.java | 235 ------ .../application/OtpVerificationCommand.java | 18 - .../verification/application/VerifiedOtp.java | 8 - .../application/port/TotpVerification.java | 13 - .../application/port/VerificationQuota.java | 20 - .../verification/domain/enums/OtpChannel.java | 8 - .../verification/domain/enums/OtpPurpose.java | 10 - .../verification/domain/enums/OtpStatus.java | 9 - .../domain/policy/OtpVerificationPolicy.java | 46 -- .../domain/value_object/VerifyOtpCommand.java | 20 - .../persistence/JpaOTPRepositoryAdapter.java | 88 --- .../persistence/JpaOtpAuditLogAdapter.java | 22 - .../persistence/OtpAuditLogEntity.java | 49 -- .../persistence/OtpAuditLogJpaRepository.java | 6 - .../persistence/OtpChallengeEntity.java | 131 ---- .../OtpChallengeJpaRepository.java | 22 - .../redis/RedisRateLimiterAdapter.java | 54 -- .../TotpAuthenticatorCodeVerifier.java | 83 -- .../interfaces/rest/OtpController.java | 97 --- .../db/legacy/oracle/V1__otp_schema.sql | 41 - .../oracle/V2__prefixed_challenge_ids.sql | 25 - .../OtpApplicationContractsTest.java | 249 ------ .../OtpApplicationServiceTest.java | 180 ----- .../domain/OTPDomainContractTest.java | 248 ------ .../identity/verification/domain/OTPTest.java | 338 --------- .../verification/domain/OtpSnapshotTest.java | 204 ----- .../domain/TypedIdentifiersTest.java | 51 -- .../OtpInfrastructureIntegrationTest.java | 544 ------------- .../delivery/DeliveryAdaptersTest.java | 32 - .../kafka/KafkaOtpEventPublisherTest.java | 44 -- .../security/HmacOtpCodeHasherTest.java | 23 - .../security/TotpVerificationTest.java | 32 - .../security/VaultSecretsIT.java | 151 ---- .../interfaces/rest/OtpControllerTest.java | 104 --- .../Dockerfile | 2 +- .../README.md | 4 +- .../pom.xml | 148 +++- .../amg/payguard/identity/IdentityApp.java | 0 .../domain/value_object/AccountId.java | 43 ++ .../domain/value_object/CustomerId.java | 0 .../domain/value_object/MembershipId.java | 44 ++ .../domain/value_object/OtpAuditId.java | 44 ++ .../customer/domain/value_object/OtpId.java | 0 .../value_object/PrefixedIdValidator.java | 0 .../domain/value_object/RegistrationId.java | 44 ++ .../domain/value_object/TenantId.java | 43 ++ .../domain/value_object/package-info.java | 0 .../jooq/generated/DefaultCatalog.java | 54 ++ .../persistence/jooq/generated/Indexes.java | 32 + .../out/persistence/jooq/generated/Keys.java | 45 ++ .../persistence/jooq/generated/Payguard.java | 76 ++ .../persistence/jooq/generated/Tables.java | 38 + .../jooq/generated/enums/IdentityStatus.java | 64 ++ .../generated/tables/IdentityAuditLog.java | 318 ++++++++ .../jooq/generated/tables/IdentityOtp.java | 362 +++++++++ .../generated/tables/IdentityOtpScope.java | 236 ++++++ .../jooq/generated/tables/IdentityOutbox.java | 280 +++++++ .../records/IdentityAuditLogRecord.java | 157 ++++ .../tables/records/IdentityOtpRecord.java | 262 +++++++ .../records/IdentityOtpScopeRecord.java | 79 ++ .../tables/records/IdentityOutboxRecord.java | 188 +++++ .../command/issue_otp/IssueOtpCommand.java | 53 ++ .../issue_otp/IssueOtpCommandResponse.java | 33 + .../command/issue_otp/IssueOtpHandler.java | 175 +++++ ...alidOtpIssuanceConfigurationException.java | 10 + .../UnsupportedOtpChannelException.java | 12 + .../OtpVerificationCommand.java | 71 ++ .../OtpVerificationHandler.java | 132 ++++ .../OtpVerificationResponse.java | 26 + ...nvalidOtpVerificationCommandException.java | 13 + .../OtpChallengeNotFoundException.java | 6 +- .../OtpRateLimitExceededException.java | 2 +- .../exception}/OtpVerificationException.java | 6 +- ...supportedOtpVerificationModeException.java | 14 + .../port/AuthenticatorSecretPort.java | 0 .../application/port/OtpDelivery.java | 0 .../application/port/OtpDeliveryRequest.java | 6 +- .../application/port/VerificationAudit.java | 5 +- .../port/VerificationAuditAction.java | 6 + .../port/VerificationAuditEntry.java | 9 +- .../port/rate_limit/RateLimitDecision.java | 57 ++ .../port/rate_limit/RateLimitOperation.java | 8 + .../port/rate_limit/RateLimitPolicy.java | 62 ++ .../port/rate_limit/RateLimitPort.java | 36 + .../port/rate_limit/RateLimitRequest.java | 53 ++ .../RateLimitUnavailableException.java | 26 + .../verification/domain/enums/OtpChannel.java | 65 ++ .../verification/domain/enums/OtpPurpose.java | 119 +++ .../verification/domain/enums/OtpStatus.java | 201 +++++ .../domain/enums/OtpVerificationMode.java | 1 - .../domain/enums/OtpVerificationResult.java | 0 .../verification/domain/event/OtpEvent.java | 0 .../domain/event/OtpEventPublisher.java | 0 .../domain/event/OtpExpiredEvent.java | 26 + .../domain/event/OtpInvalidatedEvent.java | 26 + .../domain/event/OtpIssuedEvent.java | 36 + .../domain/event/OtpLockedEvent.java | 26 + .../domain/event/OtpVerificationEvent.java | 26 + .../InvalidOtpAttemptLimitException.java | 0 .../OtpAttemptCountOutOfRangeException.java | 0 .../OtpCodeHashMustNotBeBlankException.java | 0 ...ForRandomNumericVerificationException.java | 0 ...sumedTimeMustMatchUsedStatusException.java | 0 ...medTimeOutsideValidityPeriodException.java | 0 ...iceBindingHashMustNotBeBlankException.java | 0 ...nTimeMustBeAfterCreationTimeException.java | 0 ...riodMustNotPrecedeExpirationException.java | 2 +- .../verification/domain/model/OTP.java | 79 +- .../domain/model/OtpSnapshot.java | 17 +- .../domain/model/package-info.java | 0 .../domain/policy/OtpRetentionPolicy.java | 0 .../domain/policy/OtpVerificationPolicy.java | 20 + .../domain/port/OtpCodeGenerator.java | 0 .../domain/port/OtpCodeHasher.java | 0 .../domain/port/OtpRepository.java | 15 +- .../domain/value_object/VerifyOtpCommand.java | 29 + .../adapter/in/rest/OtpController.java | 92 +++ .../adapter/in}/rest/OtpExceptionHandler.java | 14 +- .../adapter/in/rest/dto/IssueOtpRequest.java | 26 + .../adapter/in/rest/dto/IssueOtpResponse.java | 8 + .../adapter/in/rest/dto/VerifyOtpRequest.java | 22 + .../in/rest/dto/VerifyOtpResponse.java | 6 + .../adapter/in/rest/dto/package-info.java | 4 + .../out}/delivery/EmailDeliveryAdapter.java | 2 +- .../out}/delivery/MockDeliveryAdapter.java | 2 +- .../out}/delivery/PushDeliveryAdapter.java | 2 +- .../out}/delivery/SmsDeliveryAdapter.java | 2 +- .../out}/kafka/KafkaOtpEventPublisher.java | 2 +- .../jooq/JooqOTPRepositoryAdapter.java | 227 ++++++ .../jooq/JooqOtpAuditLogAdapter.java | 3 + .../jooq/JsonOtpOutboxEncoder.java | 83 ++ .../persistence/jooq/OtpOutboxEncoder.java | 17 + .../rate_limit/RedisRateLimiterAdapter.java | 118 +++ .../config/OtpConfiguration.java | 51 +- .../security/HmacOtpCodeHasher.java | 0 .../security/OtpIdGenerator.java | 0 .../security/PepperKeyRing.java | 0 .../security/SecureNumericOtpGenerator.java | 0 ...UnavailableAuthenticatorSecretAdapter.java | 0 .../VaultAuthenticatorSecretAdapter.java | 0 .../security/VaultPepperKeys.java | 35 +- .../src/main/resources/application.yaml | 51 +- .../src/main/resources/banner.txt | 0 .../db/jooq/V1__create_identity_otp_scope.sql | 13 + .../db/jooq/V2__create_identity_otp.sql | 82 ++ .../db/jooq/V3__create_identity_audit_log.sql | 17 + .../db/jooq/V4__create_identity_outbox.sql | 22 + .../postgresql/V1__identity_schema.sql | 0 .../V2__serialize_active_challenges.sql | 0 .../V3__retain_challenges_for_jooq.sql | 19 + .../postgresql/V4__create_identity_outbox.sql | 22 + .../src/main/resources/identity.yaml | 9 + .../resources/redis/scripts/token_bucket.lua | 77 ++ .../IdentityUpgradeMigrationTest.java | 114 +++ .../JooqOtpRepositoryIntegrationTest.java | 128 ++++ .../application/RateLimitDecisionTest.java | 126 ++++ .../application/RateLimitPolicyTest.java | 112 +++ .../application/RateLimitRequestTest.java | 125 +++ .../RedisRateLimiterAdapterTest.java | 80 ++ .../RedisRateLimiterIntegrationTest.java | 86 +++ .../src/test/resources/application-test.yaml | 0 .../src/test/resources/postgres-test-init.sql | 0 .../db/legacy/oracle/V1__loan_schema.sql | 6 - .../Dockerfile | 0 .../README.md | 2 +- .../pom.xml | 22 +- .../payguard/loan/LoanServiceApplication.java | 0 .../application/LoanApplicationService.java | 0 .../loan/application/LoanDataExportPort.java | 0 .../loan/application/LoanRepository.java | 0 .../loan/application/SubjectTokenVault.java | 0 .../application/WalletSettlementPort.java | 0 .../amg/payguard/loan/domain/Installment.java | 0 .../payguard/loan/domain/InterestAccrual.java | 0 .../dev/amg/payguard/loan/domain/Loan.java | 0 .../amg/payguard/loan/domain/LoanStatus.java | 0 .../amg/payguard/loan/domain/Repayment.java | 0 .../LoanKafkaConfiguration.java | 0 .../src/main/resources/application.yaml | 4 - .../src/main/resources/banner.txt | 0 .../migration/postgresql/V1__loan_schema.sql | 0 .../loan/LoanServiceApplicationTests.java | 0 .../payguard/loan/domain/LoanDomainTest.java | 0 .../src/test/resources/application-test.yaml | 0 .../src/test/resources/postgres-test-init.sql | 0 .../java/config/time/DateTimeManager.java | 26 + .../legacy/identity/V1__identity_schema.sql | 40 + .../V2__serialize_active_challenges.sql | 12 + payguard-wallet-service/README.md | 3 - .../main/java/dev/amg/payguard/WalletApp.java | 12 - .../domain/enums/FinancialAccountType.java | 9 - .../amg/payguard/domain/enums/HoldStatus.java | 8 - .../domain/enums/LedgerEntryType.java | 6 - .../domain/enums/TransactionStatus.java | 9 - .../domain/enums/TransactionType.java | 10 - .../payguard/domain/enums/WalletStatus.java | 9 - .../domain/event/FundsHoldCapturedEvent.java | 22 - .../domain/event/FundsHoldCreatedEvent.java | 27 - .../domain/event/FundsHoldExpiredEvent.java | 22 - .../domain/event/FundsHoldReleasedEvent.java | 22 - .../domain/event/MoneyDepositedEvent.java | 22 - .../domain/event/MoneyWithdrawnEvent.java | 22 - .../domain/event/TransferCompletedEvent.java | 34 - .../domain/event/WalletClosedEvent.java | 18 - .../domain/event/WalletCreatedEvent.java | 22 - .../event/WalletStatusChangedEvent.java | 27 - .../exception/CurrencyMismatchException.java | 30 - .../DoubleEntryImbalanceException.java | 30 - .../exception/HoldExpiredException.java | 30 - .../exception/InsufficientFundException.java | 30 - .../exception/InvalidHoldStateException.java | 30 - .../InvalidIdempotencyKeyException.java | 26 - .../InvalidWalletStateException.java | 30 - .../exception/LimitExceededException.java | 30 - .../NegativeAmountNotAllowedException.java | 30 - .../TransactionAlreadySettledException.java | 30 - .../exception/WalletFrozenException.java | 30 - .../domain/exception/package-info.java | 2 - .../dev/amg/payguard/domain/model/Hold.java | 132 ---- .../payguard/domain/model/LedgerEntry.java | 54 -- .../payguard/domain/model/Transaction.java | 155 ---- .../dev/amg/payguard/domain/model/Wallet.java | 206 ----- .../amg/payguard/domain/port/ClockPort.java | 8 - .../domain/port/DomainEventPublisherPort.java | 10 - .../domain/port/HoldRepositoryPort.java | 15 - .../domain/port/LedgerRepositoryPort.java | 15 - .../payguard/domain/port/LoadWalletPort.java | 15 - .../payguard/domain/port/SaveWalletPort.java | 8 - .../port/TransactionRepositoryPort.java | 14 - .../domain/value_object/AccountId.java | 24 - .../payguard/domain/value_object/HoldId.java | 24 - .../domain/value_object/IdempotencyKey.java | 25 - .../domain/value_object/LedgerEntryId.java | 24 - .../payguard/domain/value_object/Money.java | 140 ---- .../domain/value_object/TransactionId.java | 24 - .../payguard/domain/value_object/UserId.java | 24 - .../domain/value_object/WalletId.java | 28 - .../annotation/HandleWalletExceptions.java | 10 - .../annotation/SecureAuditOnFailure.java | 12 - .../aspect/RestControllerExceptionAspect.java | 88 --- .../ServiceExceptionEnricherAspect.java | 59 -- .../SuspiciousActivityTrackerAspect.java | 40 - .../aop/exception/model/UnifiedResponse.java | 14 - .../src/main/resources/application.yaml | 39 - .../src/main/resources/banner.txt | 7 - .../amg/payguard/WalletArchitectureTests.java | 13 - .../.gitignore | 0 .../.mvn/wrapper/maven-wrapper.properties | 0 .../Dockerfile | 0 payguard-wallet/README.md | 3 + .../mvnw | 0 .../pom.xml | 2 +- .../dev/amg/payguard/domain/model/Hold.java | 361 +++++---- .../payguard/domain/model/LedgerEntry.java | 91 ++- .../payguard/domain/model/Transaction.java | 639 ++++++++-------- .../dev/amg/payguard/domain/model/Wallet.java | 176 +++-- .../domain/value_object/IdempotencyKey.java | 10 +- .../payguard/domain/value_object/Money.java | 16 +- .../src/main/resources/application.yaml | 17 +- .../postgresql/V1__wallet_schema_marker.sql | 0 .../payguard/domain/model/DomainFixtures.java | 137 +++- .../amg/payguard/domain/model/HoldTest.java | 139 +++- .../domain/model/LedgerEntryTest.java | 90 ++- .../domain/model/TransactionTest.java | 499 +++++++++--- .../amg/payguard/domain/model/WalletTest.java | 150 +++- .../domain/value_object/AccountIdTest.java | 10 +- .../domain/value_object/HoldIdTest.java | 11 +- .../value_object/IdempotencyKeyTest.java | 19 +- .../value_object/LedgerEntryIdTest.java | 10 +- .../domain/value_object/MoneyTest.java | 48 +- .../value_object/TransactionIdTest.java | 10 +- .../domain/value_object/UserIdTest.java | 11 +- .../domain/value_object/WalletIdTest.java | 10 +- pom.xml | 18 +- 337 files changed, 7764 insertions(+), 7100 deletions(-) delete mode 100644 docs/decission/# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events.md delete mode 100644 payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql rename {payguard-collateral-service => payguard-collateral}/Dockerfile (100%) rename {payguard-collateral-service => payguard-collateral}/README.md (83%) rename {payguard-loan-service => payguard-collateral}/pom.xml (98%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/CollateralServiceApplication.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/java/dev/amg/payguard/collateral/infrastructure/CollateralKafkaConfiguration.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/resources/application.yaml (99%) rename {payguard-collateral-service => payguard-collateral}/src/main/resources/banner.txt (100%) rename {payguard-collateral-service => payguard-collateral}/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql (100%) rename {payguard-collateral-service => payguard-collateral}/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java (100%) rename {payguard-collateral-service => payguard-collateral}/src/test/resources/application-test.yaml (100%) rename {payguard-collateral-service => payguard-collateral}/src/test/resources/postgres-test-init.sql (100%) rename {payguard-api-gateway => payguard-gateway}/pom.xml (98%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/ApiGatewayApplication.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/client/CollateralGatewayClient.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/client/LoanGatewayClient.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/client/WalletGatewayClient.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/config/GatewayProperties.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/config/GrpcClientConfiguration.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/security/GatewaySecurityConfiguration.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/CollateralController.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/CorrelationIdFilter.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/GatewayExceptionHandler.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/GatewayJson.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactory.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/GatewaySerializationException.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/LoanController.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/WalletController.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/java/dev/amg/payguard/gateway/web/WalletRequests.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/resources/application.yaml (100%) rename {payguard-api-gateway => payguard-gateway}/src/main/resources/banner.txt (100%) rename {payguard-api-gateway => payguard-gateway}/src/test/java/dev/amg/payguard/gateway/ApiGatewayApplicationTests.java (100%) rename {payguard-api-gateway => payguard-gateway}/src/test/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactoryTest.java (100%) delete mode 100644 payguard-identity-service/ARCHITECTURE.md delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java delete mode 100644 payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java delete mode 100644 payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql delete mode 100644 payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java delete mode 100644 payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java rename {payguard-identity-service => payguard-identity}/Dockerfile (94%) rename {payguard-identity-service => payguard-identity}/README.md (89%) rename {payguard-identity-service => payguard-identity}/pom.xml (53%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/IdentityApp.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/AccountId.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/MembershipId.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpAuditId.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/RegistrationId.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/TenantId.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/DefaultCatalog.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Indexes.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Keys.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Payguard.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Tables.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/enums/IdentityStatus.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityAuditLog.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtp.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtpScope.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOutbox.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityAuditLogRecord.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpRecord.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpScopeRecord.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOutboxRecord.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommand.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommandResponse.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpHandler.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/InvalidOtpIssuanceConfigurationException.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/UnsupportedOtpChannelException.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationCommand.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationHandler.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationResponse.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/InvalidOtpVerificationCommandException.java rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception}/OtpChallengeNotFoundException.java (59%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception}/OtpRateLimitExceededException.java (71%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception}/OtpVerificationException.java (73%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/UnsupportedOtpVerificationModeException.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java (81%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java (65%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditAction.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java (84%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitDecision.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitOperation.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPolicy.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPort.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitRequest.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitUnavailableException.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java (94%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpExpiredEvent.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpInvalidatedEvent.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpIssuedEvent.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpLockedEvent.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpVerificationEvent.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java (84%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java (67%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java (93%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java (100%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java (55%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpController.java rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in}/rest/OtpExceptionHandler.java (71%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpRequest.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpResponse.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpRequest.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpResponse.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/package-info.java rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out}/delivery/EmailDeliveryAdapter.java (89%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out}/delivery/MockDeliveryAdapter.java (90%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out}/delivery/PushDeliveryAdapter.java (88%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out}/delivery/SmsDeliveryAdapter.java (88%) rename {payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure => payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out}/kafka/KafkaOtpEventPublisher.java (95%) create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOTPRepositoryAdapter.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOtpAuditLogAdapter.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JsonOtpOutboxEncoder.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/OtpOutboxEncoder.java create mode 100644 payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/redis/rate_limit/RedisRateLimiterAdapter.java rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java (76%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java (100%) rename {payguard-identity-service => payguard-identity}/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java (99%) rename {payguard-identity-service => payguard-identity}/src/main/resources/application.yaml (66%) rename {payguard-identity-service => payguard-identity}/src/main/resources/banner.txt (100%) create mode 100644 payguard-identity/src/main/resources/db/jooq/V1__create_identity_otp_scope.sql create mode 100644 payguard-identity/src/main/resources/db/jooq/V2__create_identity_otp.sql create mode 100644 payguard-identity/src/main/resources/db/jooq/V3__create_identity_audit_log.sql create mode 100644 payguard-identity/src/main/resources/db/jooq/V4__create_identity_outbox.sql rename {payguard-identity-service => payguard-identity}/src/main/resources/db/migration/postgresql/V1__identity_schema.sql (100%) rename {payguard-identity-service => payguard-identity}/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql (100%) create mode 100644 payguard-identity/src/main/resources/db/migration/postgresql/V3__retain_challenges_for_jooq.sql create mode 100644 payguard-identity/src/main/resources/db/migration/postgresql/V4__create_identity_outbox.sql create mode 100644 payguard-identity/src/main/resources/identity.yaml create mode 100644 payguard-identity/src/main/resources/redis/scripts/token_bucket.lua create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/IdentityUpgradeMigrationTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/JooqOtpRepositoryIntegrationTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitDecisionTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitPolicyTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitRequestTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterAdapterTest.java create mode 100644 payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterIntegrationTest.java rename {payguard-identity-service => payguard-identity}/src/test/resources/application-test.yaml (100%) rename {payguard-identity-service => payguard-identity}/src/test/resources/postgres-test-init.sql (100%) delete mode 100644 payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql rename {payguard-loan-service => payguard-loan}/Dockerfile (100%) rename {payguard-loan-service => payguard-loan}/README.md (83%) rename {payguard-collateral-service => payguard-loan}/pom.xml (98%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/LoanServiceApplication.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/domain/Installment.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/domain/Loan.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/domain/Repayment.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/java/dev/amg/payguard/loan/infrastructure/LoanKafkaConfiguration.java (100%) rename {payguard-loan-service => payguard-loan}/src/main/resources/application.yaml (99%) rename {payguard-loan-service => payguard-loan}/src/main/resources/banner.txt (100%) rename {payguard-loan-service => payguard-loan}/src/main/resources/db/migration/postgresql/V1__loan_schema.sql (100%) rename {payguard-loan-service => payguard-loan}/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java (100%) rename {payguard-loan-service => payguard-loan}/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java (100%) rename {payguard-loan-service => payguard-loan}/src/test/resources/application-test.yaml (100%) rename {payguard-loan-service => payguard-loan}/src/test/resources/postgres-test-init.sql (100%) create mode 100644 payguard-shared/src/main/java/config/time/DateTimeManager.java create mode 100644 payguard-shared/src/test/resources/db/legacy/identity/V1__identity_schema.sql create mode 100644 payguard-shared/src/test/resources/db/legacy/identity/V2__serialize_active_challenges.sql delete mode 100644 payguard-wallet-service/README.md delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/WalletApp.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java delete mode 100644 payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java delete mode 100644 payguard-wallet-service/src/main/resources/application.yaml delete mode 100644 payguard-wallet-service/src/main/resources/banner.txt delete mode 100644 payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java rename {payguard-wallet-service => payguard-wallet}/.gitignore (100%) rename {payguard-wallet-service => payguard-wallet}/.mvn/wrapper/maven-wrapper.properties (100%) rename {payguard-wallet-service => payguard-wallet}/Dockerfile (100%) create mode 100644 payguard-wallet/README.md rename {payguard-wallet-service => payguard-wallet}/mvnw (100%) rename {payguard-wallet-service => payguard-wallet}/pom.xml (98%) rename {payguard-wallet-service => payguard-wallet}/src/main/resources/db/migration/postgresql/V1__wallet_schema_marker.sql (100%) diff --git a/.dockerignore b/.dockerignore index 970db3b..61c00af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,15 +11,15 @@ **/__pycache__ # Only the four packaged service JARs enter application build contexts. -!payguard-identity-service/target/ -payguard-identity-service/target/* -!payguard-identity-service/target/payguard-identity-service-1.0.0-SNAPSHOT.jar -!payguard-wallet-service/target/ -payguard-wallet-service/target/* -!payguard-wallet-service/target/payguard-wallet-service-1.0.0-SNAPSHOT.jar -!payguard-loan-service/target/ -payguard-loan-service/target/* -!payguard-loan-service/target/payguard-loan-service-1.0.0-SNAPSHOT.jar -!payguard-collateral-service/target/ -payguard-collateral-service/target/* -!payguard-collateral-service/target/payguard-collateral-service-1.0.0-SNAPSHOT.jar +!payguard-identity/target/ +payguard-identity/target/* +!payguard-identity/target/payguard-identity-1.0.0-SNAPSHOT.jar +!payguard-wallet/target/ +payguard-wallet/target/* +!payguard-wallet/target/payguard-wallet-1.0.0-SNAPSHOT.jar +!payguard-loan/target/ +payguard-loan/target/* +!payguard-loan/target/payguard-loan-1.0.0-SNAPSHOT.jar +!payguard-collateral/target/ +payguard-collateral/target/* +!payguard-collateral/target/payguard-collateral-1.0.0-SNAPSHOT.jar diff --git a/.env.example b/.env.example index b32e3b5..6ada912 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,9 @@ PAYGUARD_POSTGRES_COLLATERAL_VOLUME=payguard_postgres_collateral_data PAYGUARD_VAULT_DATA_VOLUME=payguard_vault_data PAYGUARD_VAULT_AUDIT_VOLUME=payguard_vault_audit PAYGUARD_KAFKA_DATA_VOLUME=payguard_kafka_data + +# Optional localhost ports with infrastructure/compose.ide.yaml. +WALLET_POSTGRES_PORT=15431 +LOAN_POSTGRES_PORT=15432 +COLLATERAL_POSTGRES_PORT=15433 +IDENTITY_POSTGRES_PORT=15434 diff --git a/.github/workflows/_database-security.yml b/.github/workflows/_database-security.yml index c055543..832f37e 100644 --- a/.github/workflows/_database-security.yml +++ b/.github/workflows/_database-security.yml @@ -38,7 +38,7 @@ jobs: - name: Verify source contracts and build applications run: | python3 -m unittest discover -s infrastructure/tests -v - ./mvnw -B -ntp -pl payguard-identity-service,payguard-wallet-service,payguard-loan-service,payguard-collateral-service -am test package + ./mvnw -B -ntp -pl payguard-identity,payguard-wallet,payguard-loan,payguard-collateral -am test package - name: Prepare isolated fixture and allocate only new explicit volumes run: | ansible-playbook -i localhost, -c local infrastructure/operations/prepare.yml -e fresh_target=true diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 638311a..a4b8b47 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -30,17 +30,17 @@ jobs: - name: Verify Spring Boot plugin resolution run: | ./mvnw -version - ./mvnw help:effective-pom -pl payguard-wallet-service \ + ./mvnw help:effective-pom -pl payguard-wallet \ | grep -A8 -B2 spring-boot-maven-plugin - name: Build Wallet Service Image - run: docker build -f payguard-wallet-service/Dockerfile -t payguard/wallet-service:${{ github.sha }} . + run: docker build -f payguard-wallet/Dockerfile -t payguard/wallet-service:${{ github.sha }} . - name: Build Loan Service Image - run: docker build -f payguard-loan-service/Dockerfile -t payguard/loan-service:${{ github.sha }} . + run: docker build -f payguard-loan/Dockerfile -t payguard/loan-service:${{ github.sha }} . - name: Build Collateral Service Image - run: docker build -f payguard-collateral-service/Dockerfile -t payguard/collateral-service:${{ github.sha }} . + run: docker build -f payguard-collateral/Dockerfile -t payguard/collateral-service:${{ github.sha }} . - name: Build Identity Service Image - run: docker build -f payguard-identity-service/Dockerfile -t payguard/identity-service:${{ github.sha }} . + run: docker build -f payguard-identity/Dockerfile -t payguard/identity-service:${{ github.sha }} . diff --git a/.gitignore b/.gitignore index 7f96ed5..55e334c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ PAYGUARD_GOAL.md # Protected local PostgreSQL/Vault rehearsal artifacts .local/ __pycache__/ + +CLAUDE.md +payguard-identity/NEW IDEAS.MD +payguard-identity/ARCHITECTURE.md +payguard-identity/IDENTITY_CHECKLIST.MD +NO_LLM_FOR_CODING.MD \ No newline at end of file diff --git a/README.md b/README.md index ea1fe66..1f7c53b 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,275 @@ -# PayGuard - -**A privacy-focused, secure modular fintech backend for wallet ledgering, loan origination, and digital-collateral risk management.** +# PayGuard 🛡️ [![CI](https://img.shields.io/badge/CI-GitHub%20Actions-2088FF?style=flat-square&logo=githubactions&logoColor=white)](#) [![Java](https://img.shields.io/badge/Java-25-orange?style=flat-square&logo=openjdk)](https://openjdk.org) +[![Kotlin](https://img.shields.io/badge/Kotlin-JVM-7F52FF?style=flat-square&logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1.1-brightgreen?style=flat-square&logo=springboot)](https://spring.io/projects/spring-boot) -[![Architecture](https://img.shields.io/badge/Architecture-Hexagonal%20%7C%20DDD-blueviolet?style=flat-square)](https://alistair.cockburn.us/hexagonal-architecture/) -[![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-yellow?style=flat-square)](LICENSE) -[Overview](#overview) • -[Services](#services) • -[Architecture](#architecture) • -[Key Engineering Decisions](#key-engineering-decisions) • -[Getting Started](#getting-started) • -[API Documentation](#api-documentation) • -[Roadmap](#roadmap) +PayGuard is a modular, self-hostable backend for collateral valuation and Loan-to-Value (LTV) monitoring. Built as +independent services on the JVM. - +> *Know your LTV before the market tells you.* ---- +Status: **pre-alpha**. APIs, event contracts and database schemas may change before `1.0`. -## Overview +Current checkout: identity service (OTP issue and verify), loan and collateral domain ports with PostgreSQL schemas, +wallet domain model, API gateway routing, and shared protobuf contracts. +The HTTP API, valuation pipeline and risk engine shown below describe the target interface and are not implemented yet. +See [Build and local rehearsal](#build-and-local-rehearsal) for commands that work with this checkout. -PayGuard is a **privacy-focused** and **security-first** backend system designed for a single, coherent financial story: a customer locks digital assets as collateral, borrows against them, and pays the loan back — while the system continuously monitors the collateral's value and protects itself automatically if that value falls too far. +PayGuard serves lenders, factorers, BNPL providers and banks. A tenant registers collateral (invoices, inventory, +equipment, real estate, commodities, securities, crypto and tokenized assets), attaches it to loans, and PayGuard keeps +the LTV current, raises margin calls, and triggers liquidation when thresholds are crossed. -Built on the principle that sensitive systems should run entirely on your own hardware to keep data completely private[cite: 2], PayGuard is designed to be self-hosted, ensuring you retain absolute control over financial records. The project is constructed as a set of **independently deployable microservices**, each owning its own completely isolated database. They communicate only through explicit contracts — never through a shared schema — bringing the risk of data leakage to absolute zero. +First clone the repository and build it: -It is a deliberate engineering exercise in the domain problems that sit underneath every regulated lending product, executed with the highest security standards: +```bash +git clone https://github.com//payguard.git +cd payguard +./mvnw -B -ntp clean verify +``` -- **Immutable and privacy-preserving ledgers:** Double-entry bookkeeping and derived, never-stored balances to eliminate data tampering. -- **Secure and idempotent financial operations:** Guaranteed transaction integrity under concurrent access. -- Loan amortization, repayment waterfalls, and delinquency state machines built for strict auditability. -- Loan-to-Value (LTV) risk monitoring, margin calls, and liquidation via protected, automated mechanisms. -- **Strict service-to-service communication boundaries:** REST at the edge for external clients, and secure gRPC where internal latency and security matter. +Then start the infrastructure (PostgreSQL and Vault) and the applications: -It is not a payment-card switch or a full core-banking platform. The scope is intentionally narrow so that every part of the architecture can be built, understood, and defended in depth against security vulnerabilities. +```bash +docker compose --profile applications up +``` ---- +- Starts one private PostgreSQL database per business service +- Starts Vault with a scoped AppRole per service +- Runs Flyway migrations with a separate migration identity before applications start + +Talk to it over REST (target API): + +```http +POST /v1/loans +Authorization: Bearer + +{ + "borrower_id": "b_123", + "principal": 100000, + "currency": "USD", + "interest_rate": 0.08, + "term_months": 12, + "asset_ids": ["a_456"], + "ruleset_id": "default" +} +``` -## Core Capabilities +```http +GET /v1/loans/{id}/ltv -### Implemented / In Progress +{ "ltv_current": 0.62, "status": "healthy", "buffer": 0.18, "components": [ ... ] } +``` -- [ ] Account creation and double-entry ledger posting (`wallet-service`) -- [ ] Hold → capture → release flow for provisional balance reservations -- [ ] Loan application, approval, and amortization-schedule generation (`loan-service`) -- [ ] Repayment waterfall (fees → interest → principal) -- [ ] Collateral locking and Loan-to-Value calculation (`collateral-service`) -- [ ] Margin-call and liquidation risk monitoring -- [x] API Gateway routing (REST-in, gRPC-out to internal services) +## Start using PayGuard -### Planned +Register collateral, request a valuation, then read the LTV: -- [ ] Kafka-based domain events between services (e.g. loan approval → collateral linkage) -- [x] Full CI pipeline (build, test, static analysis) via GitHub Actions -- [ ] Expanded integration and concurrency test coverage +```http +POST /v1/assets { type, external_id, borrower_id, currency, metadata, documents } +POST /v1/assets/{id}/valuations { trigger, requested_by } +GET /v1/loans/{id}/ltv +PUT /v1/loans/{id}/thresholds { ltv_initial_max, ltv_maintenance, ltv_liquidation } +``` -> This README describes the target architecture and is kept in sync with implementation status via the checkboxes above — see [Current Limitations](#current-limitations) for what is intentionally out of scope for now. +Subscribe to events instead of polling: ---- +```http +POST /v1/webhooks { url, events: ["loan.margin_call", "loan.liquidation"], secret, active } +``` -## Services +Events include `asset.created`, `valuation.updated`, `valuation.failed`, `asset.stale_price`, `risk_rule.violated`, +`loan.margin_call` and `loan.liquidation`. -| Service | Responsibility | Persistence | Current State | -|---|---|---|---| -| `wallet-service` | Account balances, double-entry ledger, holds/captures | Dedicated PostgreSQL database with jOOQ Open Source configured; SQL adapter pending | In development | -| `loan-service` | Loan lifecycle, amortization schedules, repayments | Dedicated PostgreSQL schema; JPA repository adapter pending | In development | -| `collateral-service` | Collateral locking, price valuation, LTV monitoring, liquidation | Dedicated PostgreSQL schema; JPA repository adapter pending | In development | -| `api-gateway` | Single entry point; REST from clients, gRPC to internal services | — | Planned | +## Principles -> `wallet-service` deliberately uses jOOQ instead of JPA: a ledger is append-only, and jOOQ gives explicit control over `INSERT`-only SQL with no risk of an accidental `UPDATE` slipping in through dirty-checking. `loan-service` and `collateral-service` manage mutable, CRUD-shaped state, where JPA is a better fit. +#### Isolated ---- +Each business service owns its database, runtime group, schema owner and Vault AppRole. Services talk only through +explicit contracts: REST at the edge, gRPC between services, domain events for state changes. There are no foreign keys +and no table reads across service databases. + +#### Auditable + +The ledger is double-entry and append-only. Balances are derived, never stored. Every state change writes an +immutable audit record with actor, action and entity. Financial operations are idempotent under concurrent access. + +#### Explicit + +Domain rules live in the domain, use-case coordination in the application layer, and JDBC, JPA, jOOQ and Vault details +in infrastructure (hexagonal architecture with DDD). Thresholds, haircuts and risk rules are data you configure per +tenant, with documented defaults. + +#### Principled + +PayGuard does not try to be a full core-banking platform or a payment-card switch. The scope is narrow so each part can +be built, understood and defended in depth. + +Example limitations: + +- No provider delivery, customer enrollment or external authentication yet. +- No HA or auto-unseal for Vault yet. +- No durable event delivery yet. Kafka events are planned. +- Valuation quality depends on the external price providers you connect. ## Architecture -### System Overview +```text +Customers (Lender / Factorer / BNPL / Bank) + │ HTTPS + Webhook + ▼ +API Gateway → Rate Limiter → Auth + │ gRPC + ▼ +┌─────────────────────────────────────────────┐ +│ Core Services │ +│ Identity │ Asset Registry │ Valuation │ +│ Wallet │ Risk Engine │ Alert │ +│ Loan │ Collateral/LTV │ Webhook │ +│ Audit Log │ Billing │ Reporting │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Data Layer │ +│ PostgreSQL (per service) │ Kafka (events) │ +│ Vault (secrets) │ Object storage │ +└─────────────────────────────────────────────┘ + │ + ▼ +External data providers (Chainlink, Pyth, CoinGecko, +Refinitiv, Zillow, Dun & Bradstreet, ...) +``` -## Current services +### Valuation pipeline -| Module | Persistence available in source | Current database integration | -| --- | --- | --- | -| `payguard-identity-service` | JPA challenge/audit adapters; existing-customer OTP issue/verify | PostgreSQL 17.11, Flyway, serialized verification/replacement; Vault database leases and scoped KV secrets | -| `payguard-loan-service` | Domain/application ports and loan SQL schema; concrete repository adapter absent | PostgreSQL schema and real precision/constraint tests; Vault pool | -| `payguard-collateral-service` | Domain/application ports and collateral SQL schema; concrete repository adapter absent | PostgreSQL schema and real valuation/constraint tests; Vault pool | -| `payguard-wallet-service` | Domain/ports and OSS jOOQ dependency; no SQL schema, adapter or code-generation configuration | POSTGRES dialect and Vault pool configured; ledger persistence remains separate work | -| `payguard-api-gateway`, `payguard-proto` | No owned database | Database-free | +Trigger → source fan-out (2 s timeout, retries) → normalizer → outlier filter → weighted aggregator → +confidence scorer → haircut engine → persister → event emitter. -Identity contains access, customer, verification and registration modules in one deployment and one owned database. Access and registration workflows remain largely planned. The retired step-up-token endpoint is not restored. +```text +haircut = min(0.95, base + 0.5 × σ30d + 100k / volume + FX + concentration + staleness) +``` -## Ownership and engineering decisions +Confidence at or above 0.75 is high, 0.45 to 0.75 is medium, below 0.45 is low and raises an alert. -Each business service has its own PostgreSQL database, runtime group, durable schema owner and independently scoped Vault AppRole. Local rehearsal uses four private PostgreSQL containers. Runtime roles have DML privileges; Flyway runs through a separate migration identity. Applications never receive a database administrator or Vault root token. +### Risk rules -Domain rules remain in the domain, use-case coordination in application code, and JDBC/JPA/jOOQ/Vault details in infrastructure. Wallet's intended append-only ledger and hold/capture separation remain design decisions; the absent SQL adapter means their persisted concurrency behavior has not been established. Loan and Collateral continue to use their existing JPA dependency rather than being rewritten to jOOQ. Cross-service access belongs behind explicit ports, with no foreign keys or table reads across service databases. +- **LTV thresholds:** initial max 0.70, maintenance 0.80 (margin call), liquidation 0.90 (after a 24 hour grace period). + Configurable per asset class. +- **Concentration limits:** per asset, borrower, sector, country, currency, asset class and more. +- **Currency mismatch:** extra haircut for cross-currency collateral. +- **Stale prices:** per asset class, from a freeze with a critical alert to a daily haircut increase. +- **Circuit breakers:** asset-class, asset, provider and system kill switch. +- **AML and KYC:** tiered limits and sanctions screening. +- **SLA escalation:** acknowledge and resolve deadlines per severity. -- [PostgreSQL ownership and cutover ADR](docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md) -- [Vault secrets, bootstrap and licensing ADR](docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md) -- [Database credential and pool lifecycle ADR](docs/decission/ADR-0013-database-leases-and-pool-replacement.md) -- [OTP concurrency ADR](docs/decission/ADR-0014-otp-postgresql-concurrency.md) -- [Identity architecture](payguard-identity-service/ARCHITECTURE.md) +### Supported collateral -## Build and local rehearsal +| Class | Examples | Refresh | +|---------------|-----------------------------------------------|--------------------------| +| Physical | Invoice, inventory, equipment, real estate | Daily to monthly | +| Markets | Commodities, securities | 15 minutes to 60 seconds | +| Digital | Crypto spot, DeFi collateral, LP tokens, LSTs | 5 to 60 seconds | +| Other digital | NFTs, tokenized real-world assets | 15 minutes to hourly | + +## Services -Use JDK **25**, Docker/Compose, Ansible and OpenSSL. The Maven wrapper is included. Database integration tests start real PostgreSQL containers; they do not require an unset opt-in flag. +Each service is an independently deployable module. + +| Artifact | Purpose | +|-------------------------------|-----------------------------------------------------------------------| +| `payguard-identity-service` | Access, customer, verification and registration. OTP issue and verify | +| `payguard-wallet-service` | Account balances, double-entry ledger, holds and captures | +| `payguard-loan-service` | Loan lifecycle, amortization schedules, repayment waterfall | +| `payguard-collateral-service` | Collateral locking, LTV calculation, margin calls, liquidation | +| `payguard-api-gateway` | Single entry point. REST from clients, gRPC to internal services | +| `payguard-proto` | Shared protobuf contracts | +| `payguard-valuation-service` | Price fan-out, aggregation, haircuts, confidence scoring (planned) | +| `payguard-risk-service` | Risk rules engine and concentration checks (planned) | +| `payguard-alert-service` | Alerts, escalation and webhook delivery (planned) | +| `payguard-audit-service` | Immutable audit log (planned) | +| `payguard-billing-service` | Usage metering and invoicing (planned) | +| `payguard-reporting-service` | KPI dashboards and exports (planned) | + +### Current state + +| Module | Persistence available in source | Current database integration | +|------------------------------------------|--------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------| +| `payguard-identity-service` | JPA challenge and audit adapters; existing-customer OTP issue and verify | PostgreSQL 17.11, Flyway, serialized verification and replacement; Vault database leases and scoped KV secrets | +| `payguard-loan-service` | Domain and application ports, loan SQL schema; repository adapter absent | PostgreSQL schema and real precision and constraint tests; Vault pool | +| `payguard-collateral-service` | Domain and application ports, collateral SQL schema; repository adapter absent | PostgreSQL schema and real valuation and constraint tests; Vault pool | +| `payguard-wallet-service` | Domain, ports and OSS jOOQ dependency; no SQL schema, adapter or code generation yet | PostgreSQL dialect and Vault pool configured; ledger persistence is separate work | +| `payguard-api-gateway`, `payguard-proto` | No owned database | Database-free | + +> `wallet-service` uses jOOQ instead of JPA on purpose: a ledger is append-only, and jOOQ gives explicit control over +> `INSERT`-only SQL with no risk of an accidental `UPDATE` through dirty-checking. `loan-service` and +> `collateral-service` manage mutable, CRUD-shaped state, where JPA is a better fit. + +## Requirements + +PayGuard runs on the JVM. Use JDK **25**, Docker and Compose, Ansible and OpenSSL. The Maven wrapper is included. +Database integration tests start real PostgreSQL containers. + +## Build and local rehearsal ```bash ./mvnw -B -ntp clean verify ``` -Historical Identity verification is recorded in [the publication repair log](docs/tasks/identity-publication-repair.md). It does not validate the new Vault Agent lifecycle; see [current infrastructure validation](docs/infrastructure-validation.md). +For the Compose, Vault Agent, Ansible and Flyway CLI deployment, +follow [the infrastructure runbook](docs/infrastructure.md). +It gives the exact preparation, initialization, migration and test commands. + +- Generated credentials, certificates and unseal material live under ignored `.local/payguard` with protected + permissions. +- `.env.example` contains only public settings. Never copy secrets into an image or publish that directory. +- Runtime Flyway is disabled. Apply migrations with the migration identity before starting applications. +- The `applications` Compose profile is separate from the database and Vault infrastructure. +- Runtime roles have DML privileges only. Applications never receive a database administrator or Vault root token. -For the Compose, Vault Agent, Ansible and Flyway CLI deployment, follow [the infrastructure runbook](docs/infrastructure.md). It gives the exact preparation, initialization, migration and test commands. Generated credentials, certificates and unseal material live under ignored `.local/payguard` with protected permissions. `.env.example` contains only public settings. Never copy secrets into an image or publish that directory. +## Documentation -Runtime Flyway is disabled: apply migrations using the migration identity before starting applications. The `applications` Compose profile is separate from the database/Vault infrastructure. Provider delivery, customer enrollment, external authentication, production certificate issuance, HA/auto-unseal and durable event delivery are not implemented by this migration. +- [Infrastructure runbook](docs/infrastructure.md) + and [current infrastructure validation](docs/infrastructure-validation.md) +- [Identity architecture](payguard-identity/ARCHITECTURE.md) +- [PostgreSQL ownership and cutover ADR](docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md) +- [Vault secrets, bootstrap and licensing ADR](docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md) +- [Database credential and pool lifecycle ADR](docs/decission/ADR-0013-database-leases-and-pool-replacement.md) +- [OTP concurrency ADR](docs/decission/ADR-0014-otp-postgresql-concurrency.md) +- [Migration guide](docs/guides/postgresql-vault-migration.md), [work log](docs/tasks/postgresql-vault-migration.md) + and [follow-up issue drafts](docs/issues/postgresql-vault-follow-up.md) ## Existing data and historical decisions -Oracle scripts are preserved byte-for-byte under each owning module's `db/legacy/oracle`. PostgreSQL has a separate migration history and never executes those scripts. No existing volume is deleted or treated as empty. Legacy UUID challenge/customer records require an approved persistent identifier mapping; no identities are regenerated to make an import succeed. +PostgreSQL migrations live under each owning module's `db/migration/postgresql`. No existing volume is deleted or treated as empty. Legacy UUID +records need an approved persistent identifier mapping; no identities are regenerated to make an import succeed. + +The cutover is an offline write freeze, a rehearsed export and import, and reconciliation. After PostgreSQL accepts new +writes, returning to a previous database requires reverse migration or reconciliation. Historical ADRs keep their original context, +with database decisions superseded by ADR 0011 to 0014. + +## Reporting Issues / Support + +Please use the [issue tracker](../../issues) for feature requests and bugs. For questions, open a +[discussion](../../discussions). + +## Reporting Security Vulnerabilities + +If you find a security vulnerability in PayGuard, please do not open a public issue. Report it through the repository's +private security advisory feature. See [SECURITY.md](SECURITY.md). + +## Inspirations + +The domain problems under every regulated lending product: double-entry ledgers, margin lending and collateral +management. The module and decision-record discipline of Spring, Kafka and OkHttp. + +## Contributing + +Please see [the contribution guide](CONTRIBUTING.md) and the [Code of conduct](CODE_OF_CONDUCT.md) before contributing. +Written code rules are in [CODE_RULES.md](CODE_RULES.md). -The cutover is an offline write freeze, rehearsed export/import and reconciliation. After PostgreSQL accepts new writes, returning to Oracle requires reverse migration or reconciliation; retaining an old volume alone is insufficient. Historical ADRs retain their original context, with database-related decisions superseded by ADR 0011–0014. +## License -See [the migration guide](docs/guides/postgresql-vault-migration.md), [durable work log](docs/tasks/postgresql-vault-migration.md) and [separate issue drafts](docs/issues/postgresql-vault-follow-up.md) for actual verification evidence and remaining work. +PayGuard is distributed under the terms of the MIT License. See [LICENSE](LICENSE). diff --git a/docs/decission/# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events.md b/docs/decission/# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events.md deleted file mode 100644 index a3d0ec8..0000000 --- a/docs/decission/# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR-0002: Use Kafka (not RabbitMQ) for cross-service events - -## Status -Accepted - -## Context -`wallet-service`, `loan-service`, and `collateral-service` need to coordinate on events like -`LoanActivationRequested`, `CollateralLocked`, `CollateralLiquidated`, and `FundsDisbursed` — the -backbone of the loan-origination saga described in our domain-invariants discussion. Requirements -that shape the messaging choice: - -- **Replay/audit**: a fintech-style system needs to be able to reconstruct "what happened and in - what order" after the fact — for reconciliation, debugging a saga that got stuck, or answering - "why is this loan in this state." A message that's gone the moment it's consumed doesn't support - that. -- **Multiple independent consumers per event**: e.g. `CollateralLiquidated` may need to be consumed - by `loan-service` (to transition the Loan) and potentially a future audit/reporting service, - without them competing for the same message. -- **Ordering per aggregate**: events about the same `Loan`/`CollateralPosition` need to be processed - in order relative to each other, which maps naturally onto partitioning by aggregate id. -- **This is a portfolio project aimed at the EU fintech job market**, where Kafka is the de facto - standard for event-driven backends — demonstrable Kafka experience (consumer groups, partitioning, - offset management, schema evolution) is more directly relevant to target roles than RabbitMQ - experience. - -## Decision -Use **Kafka** as the backbone for all cross-service domain events in PayGuard (wallet-service, -loan-service, collateral-service), with topics partitioned by aggregate id (e.g. `loanId`, -`collateralPositionId`) to preserve per-aggregate ordering. - -## Consequences - -**Positive** -- Events are retained on the log (per configured retention), so replay for debugging, audit, or - rebuilding a read model later is possible without extra plumbing. -- Consumer groups let each service (and any future service) read the same event stream - independently, at its own pace, without competing consumption. -- Partitioning by aggregate id gives ordering guarantees exactly where the saga needs them, without - needing a global lock. -- Directly demonstrates the event-streaming skill set most relevant to the target job market. - -**Negative** -- Kafka is heavier to operate than RabbitMQ (ZooKeeper/KRaft, broker configuration, topic/partition - planning) — meaningful overhead for a project run by a small team. -- Request/reply-style interactions (if any service ever needs a synchronous "ask and wait") are - awkward on Kafka compared to RabbitMQ's native RPC-style patterns; those cases should go over - gRPC instead, not be forced onto Kafka. -- No native per-message TTL/priority queues the way RabbitMQ has; anything needing that has to be - built on top (e.g. a delay via a scheduled retry topic). - -## Alternatives considered -- **RabbitMQ** — better fit if the system were purely command/task-queue style with no replay need, - and operationally simpler. Rejected because audit/replay and multi-consumer fan-out are core - requirements here, not edge cases. -- **Direct synchronous calls (gRPC/REST) for saga coordination** — rejected as the primary - mechanism because it couples services' availability together and gives up the event log; gRPC is - still used for API-gateway-to-service calls where synchronous request/response is the right - shape, just not for saga coordination. \ No newline at end of file diff --git a/docs/infrastructure-validation.md b/docs/infrastructure-validation.md index 2f79948..5dd6976 100644 --- a/docs/infrastructure-validation.md +++ b/docs/infrastructure-validation.md @@ -1,72 +1,13 @@ -# Vault and native infrastructure validation +# Validation after restoring stashed work -This branch is based on `main` at `c904d71ef8a7541885cbbe887f25148e196ef768`. -It contains the infrastructure replacement, adapted to the committed service module names. -The owner's uncommitted module renames, Identity refactor, Wallet edits, and local tooling -remain outside the branch. No live database, Vault state, or deployment was accessed. +Current source and evidence are documented in [the recovery record](recovery/stashed-work.md). -## Scope +The recovered reactor packages and compiles its tests. 302 selected Java tests and 18 offline +infrastructure contracts pass. PostgreSQL 18 migration/import SQL checks passed using PGlite; +Docker-backed runtime, TLS, Vault renewal, recovery and business end-to-end acceptance remain +pending. Recovered Wallet formatting fails the full formatting gate, and the unfinished Identity +audit adapter remains an application-startup blocker. -- Compose manages PostgreSQL 18, Vault, dependencies, networks, application Agents and - independent Agent/Flyway migration jobs. Existing persistent volumes have explicit external names. -- Vault Agent handles AppRole authentication, token/credential renewal and application restart - on credential replacement. Java consumes Agent token files for scoped KV access. -- Ansible replaces preparation, provisioning, initialization, migration orchestration, import, - identity refresh, backup and recovery launchers. The root `scripts/` directory and custom - Java migration/import/leased-datasource implementations are removed. -- The four existing versioned PostgreSQL SQL files are byte-for-byte unchanged; their hashes - are recorded in `infrastructure/migrations.sha256`. Wallet receives a separate V1 marker, - not a financial persistence implementation. -- Identity import and privilege checks retain `identity_challenge`, `identity_audit_log`, and - `identity_challenge_scope`. No SQL from the owner's unfinished Identity refactor is imported. -- Existing Identity KV scoping and pepper-rotation tests are retained. Their fixture helper now - requires Agent-delivered token paths in `PAYGUARD_REHEARSAL_OPERATOR_TOKEN_FILE`, - `PAYGUARD_REHEARSAL_IDENTITY_TOKEN_FILE`, and `PAYGUARD_REHEARSAL_WALLET_TOKEN_FILE`. - Those live Vault tests were not executed here. - -## Executed checks - -| Check | Result | Limit | -| --- | --- | --- | -| Offline deployment/security contracts | PASS, 18 tests | Configuration and HCL/TOML parsing, not live leases | -| Compose base/application/migration/operations configuration | PASS | Compose 2.40.3; no daemon | -| Compose production overlay configuration | PASS | Dummy external broker hosts and CA paths | -| Ansible playbook syntax | PASS, 21 entry points | `preflight.yml` is an imported task list, not a playbook | -| Fresh preparation | PASS | OpenSSL and Ansible on a new isolated fixture | -| Repeated fresh preparation | PASS, safely refused | Generated bytes unchanged; private keys/passwords/config 0600, directories 0700 | -| Existing versioned PostgreSQL SQL | PASS, 4 files unchanged | No live Flyway history available | -| Shared Agent token/TLS/error-sanitization Java tests | PASS, 5 tests | No live Vault; repository style goals skipped for this run | -| Application packaging and test-source compilation | PASS, all four services | Runtime tests skipped; Spotless/Checkstyle bypassed in this packaging run | -| Reactor Maven validation | PASS | Spotless and Checkstyle for parent/shared/four services; PMD/SpotBugs not run | -| Changed Java formatting | PASS | Google Java Format 1.30.0 | -| Original checkout WIP preservation | PASS | Original status, staging state and file hashes unchanged | - -The Java test command used JDK 25 and the repository Maven wrapper: - -```bash -./mvnw -B -ntp -pl payguard-shared \ - -Dspotless.skip=true -Dcheckstyle.skip=true \ - -Dtest=AgentTokenClientTest,PostgreSqlTlsTest,SanitizedDependencyErrorTest test -python3 -m unittest discover -s infrastructure/tests -v -``` - -Dependency downloads used private, workspace-only proxy settings and the system Java trust -store. TLS verification remained enabled; proxy settings and generated secrets are not committed. - -## Required live acceptance - -The reusable native infrastructure CI workflow runs on an isolated Docker host. It prepares -separate volumes, provisions Vault/databases, migrates every database twice, verifies privileges, -starts the applications and rehearses a Vault outage. The workflow is executable test scaffolding, -not evidence that those jobs have passed. - -Production acceptance remains incomplete. Run integration, permission-denial, TLS failure, -Agent renewal/reauthentication, credential replacement, interrupted migration/recovery, -backup/restore, import rollback and business end-to-end tests before deployment. Test held SQL -transactions and Vault outages explicitly; credential expiration does not terminate every -already executing database statement. Complete paired PostgreSQL/Raft restoration and check -existing records, histories, ownership and secret versions on isolated recovery volumes. - -Base Kafka is a local plaintext rehearsal broker. The production overlay requires authenticated -TLS external Redis/Kafka and verified CA/hostname settings. This is a single-host deployment -building block, not a tested highly available database or Vault deployment. +Historical infrastructure-only evidence from merged PR #40 is retained separately in +[infrastructure-before-recovery.md](recovery/infrastructure-before-recovery.md). It must not be +interpreted as validation of the recovered business refactor. diff --git a/docs/infrastructure.md b/docs/infrastructure.md index e9272f0..d4e47d7 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -125,8 +125,8 @@ OTP key is missing, reconciliation fails rather than silently replacing that sta historical SQL and Flyway checksums unchanged. An incompatible history needs a separate reviewed forward migration; baseline/repair is deliberately not automatic. -The replacement import retains the committed `identity_challenge`, `identity_audit_log`, and -`identity_challenge_scope` tables. No old application data is +The replacement import retains the committed `identity_otp`, `identity_audit_log`, and +`identity_otp_scope` tables. No old application data is converted or rewritten during adoption. ## Fresh isolated environment @@ -166,7 +166,7 @@ Build the committed service modules with JDK 25, then migrate each database expl This branch retains the existing module names and application persistence implementations: ```bash -./mvnw -B -ntp -pl payguard-identity-service,payguard-wallet-service,payguard-loan-service,payguard-collateral-service -am package +./mvnw -B -ntp -pl payguard-identity,payguard-wallet,payguard-loan,payguard-collateral -am package for service in identity wallet loan collateral; do docker compose run --rm --no-deps operations infrastructure/operations/migrate.yml -e service="$service" done @@ -225,13 +225,38 @@ single-host deployment building block, not an HA database/Vault design. Deployme still requires live integration, security, recovery and business end-to-end checks. See the current validation report for executed checks and remaining acceptance work. +## IDE database connections + +To publish only the four database ports on localhost, run from the repository root: + +```bash +docker compose -f docker-compose.yaml -f infrastructure/compose.ide.yaml up -d --no-deps postgres-wallet postgres-loan postgres-collateral postgres-identity +``` + +Use the PostgreSQL driver, host `127.0.0.1`, schema `payguard`, and these defaults: + +| Service | Port | Database | +| --- | --- | --- | +| Wallet | 15431 | payguard_wallet | +| Loan | 15432 | payguard_loan | +| Collateral | 15433 | payguard_collateral | +| Identity | 15434 | payguard_identity | + +For local administrative inspection, the username is `postgres` and each database's password is in `.local/payguard//admin-password`. This account has superuser privileges; do not use it in application configuration. Application credentials remain managed by Vault Agent. + +Enable SSL with mode `verify-full` and CA certificate `.local/payguard/ca.crt` (use an absolute path in the IDE). No client certificate or key is required. Server certificates must include `IP:127.0.0.1` in their subject alternative names; older installations with only container DNS names need their server certificates reissued with the same CA before using this host. Fresh preparation already includes localhost names. + +Override the ports with the `*_POSTGRES_PORT` settings from `.env.example`. The main Compose file alone keeps database ports private. The override reuses the existing external database volumes and does not initialize a replacement deployment. + +If a local `.local/payguard/ide-image.yaml` exists, it records the exact previously installed PostgreSQL image for this workstation. Add `-f .local/payguard/ide-image.yaml` after the IDE override and use `--pull never` to reuse that image when the project-pinned image cannot be downloaded. This local file is ignored by Git and does not change the normal deployment image. + ## Identity import `import-identity.yml` replaces both the Python converter and Java import runner. Supply protected `source_file`, `mapping_file`, and `approved_mapping_sha256` variables in a protected variable file. The mapping JSON requires `approved_by`, `approval_reference`, `customers`, and `challenges`. Already-correct prefixed IDs pass through; historical IDs require explicit mappings. The source -contains `challenges` and `audit` arrays. Audit records retain their `challenge_id` references. +contains `challenges` and `audit` arrays. Historical audit `challenge_id` fields map to `otp_id` in the recovered schema. The native PostgreSQL transaction sets the stable owner, locks the destination, refuses populated challenge/audit/scope tables, binds JSON values as parameters, checks integer/timestamp inputs, diff --git a/infrastructure/application.Dockerfile b/infrastructure/application.Dockerfile index 895b362..52ce0c5 100644 --- a/infrastructure/application.Dockerfile +++ b/infrastructure/application.Dockerfile @@ -4,6 +4,6 @@ FROM eclipse-temurin:25-jre-alpine@sha256:b7528962e65d43064d8a8b67ba175367896bc9 COPY --from=vault /bin/vault /usr/local/bin/vault ARG SERVICE WORKDIR /app -COPY payguard-${SERVICE}-service/target/payguard-${SERVICE}-service-1.0.0-SNAPSHOT.jar /app/service.jar +COPY payguard-${SERVICE}/target/payguard-${SERVICE}-1.0.0-SNAPSHOT.jar /app/service.jar USER 1000:1000 ENTRYPOINT ["vault", "agent"] diff --git a/infrastructure/compose.operations.yaml b/infrastructure/compose.operations.yaml index 9e3871d..5a5c67b 100644 --- a/infrastructure/compose.operations.yaml +++ b/infrastructure/compose.operations.yaml @@ -79,7 +79,7 @@ services: bind: create_host_path: false - type: bind - source: ./payguard-identity-service/src/main/resources/db/migration/postgresql + source: ./payguard-identity/src/main/resources/db/migration/postgresql target: /flyway/sql read_only: true bind: @@ -184,7 +184,7 @@ services: bind: create_host_path: false - type: bind - source: ./payguard-wallet-service/src/main/resources/db/migration/postgresql + source: ./payguard-wallet/src/main/resources/db/migration/postgresql target: /flyway/sql read_only: true bind: @@ -289,7 +289,7 @@ services: bind: create_host_path: false - type: bind - source: ./payguard-loan-service/src/main/resources/db/migration/postgresql + source: ./payguard-loan/src/main/resources/db/migration/postgresql target: /flyway/sql read_only: true bind: @@ -394,7 +394,7 @@ services: bind: create_host_path: false - type: bind - source: ./payguard-collateral-service/src/main/resources/db/migration/postgresql + source: ./payguard-collateral/src/main/resources/db/migration/postgresql target: /flyway/sql read_only: true bind: diff --git a/infrastructure/flyway/callbacks/identity/afterMigrate.sql b/infrastructure/flyway/callbacks/identity/afterMigrate.sql index 2b5097a..c2c4491 100644 --- a/infrastructure/flyway/callbacks/identity/afterMigrate.sql +++ b/infrastructure/flyway/callbacks/identity/afterMigrate.sql @@ -29,8 +29,8 @@ END $audit$; DO $scope$ BEGIN - IF to_regclass('payguard.identity_challenge_scope') IS NOT NULL THEN - REVOKE DELETE ON payguard.identity_challenge_scope FROM identity_runtime; + IF to_regclass('payguard.identity_otp_scope') IS NOT NULL THEN + REVOKE DELETE ON payguard.identity_otp_scope FROM identity_runtime; END IF; END $scope$; diff --git a/infrastructure/flyway/callbacks/identity/afterMigrateError.sql b/infrastructure/flyway/callbacks/identity/afterMigrateError.sql index 2b5097a..c2c4491 100644 --- a/infrastructure/flyway/callbacks/identity/afterMigrateError.sql +++ b/infrastructure/flyway/callbacks/identity/afterMigrateError.sql @@ -29,8 +29,8 @@ END $audit$; DO $scope$ BEGIN - IF to_regclass('payguard.identity_challenge_scope') IS NOT NULL THEN - REVOKE DELETE ON payguard.identity_challenge_scope FROM identity_runtime; + IF to_regclass('payguard.identity_otp_scope') IS NOT NULL THEN + REVOKE DELETE ON payguard.identity_otp_scope FROM identity_runtime; END IF; END $scope$; diff --git a/infrastructure/migrations.sha256 b/infrastructure/migrations.sha256 index 7871f0a..85d7b34 100644 --- a/infrastructure/migrations.sha256 +++ b/infrastructure/migrations.sha256 @@ -1,4 +1,4 @@ -24bd7ec38e26ccece80748bea0b416404280616d9ed8314b8482b09001ccab37 payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql -80029ae2b4d46a8318e897600aae44b126640c0eb6ac7d41649a80ca965366a5 payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql -fd430eae5728d056e9cb023a56d7d10f82b7e3ef86b41e3da3191fa6b4ccd4dc payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql -c3c87edc1dfa52c1f96827a402009c2d6fccf33ca8e2ee4b7ee1ff3821d25471 payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql +24bd7ec38e26ccece80748bea0b416404280616d9ed8314b8482b09001ccab37 payguard-identity/src/main/resources/db/migration/postgresql/V1__identity_schema.sql +80029ae2b4d46a8318e897600aae44b126640c0eb6ac7d41649a80ca965366a5 payguard-identity/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql +fd430eae5728d056e9cb023a56d7d10f82b7e3ef86b41e3da3191fa6b4ccd4dc payguard-loan/src/main/resources/db/migration/postgresql/V1__loan_schema.sql +c3c87edc1dfa52c1f96827a402009c2d6fccf33ca8e2ee4b7ee1ff3821d25471 payguard-collateral/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql diff --git a/infrastructure/operations/tests/integration.yml b/infrastructure/operations/tests/integration.yml index 04289c4..db36193 100644 --- a/infrastructure/operations/tests/integration.yml +++ b/infrastructure/operations/tests/integration.yml @@ -47,7 +47,7 @@ query: | SELECT count(*)::int AS missing FROM pg_tables WHERE schemaname='payguard' AND tablename<>'flyway_schema_history' - AND tablename NOT LIKE '%_audit_log' AND tablename <> 'identity_challenge_scope' + AND tablename NOT LIKE '%_audit_log' AND tablename <> 'identity_otp_scope' AND NOT (has_table_privilege('{{ item }}_runtime',format('payguard.%I',tablename),'SELECT') AND has_table_privilege('{{ item }}_runtime',format('payguard.%I',tablename),'INSERT') AND has_table_privilege('{{ item }}_runtime',format('payguard.%I',tablename),'UPDATE') diff --git a/infrastructure/postgresql/entrypoint.sh b/infrastructure/postgresql/entrypoint.sh index 1011afe..6be6750 100755 --- a/infrastructure/postgresql/entrypoint.sh +++ b/infrastructure/postgresql/entrypoint.sh @@ -3,7 +3,8 @@ set -eu # Refuse a different existing layout instead of initializing an empty replacement cluster. # PGDATA may be overridden only after inspecting the existing volume read-only. : "${PGDATA:=/var/lib/postgresql/18/docker}" -for version in $(find /var/lib/postgresql -name PG_VERSION -type f); do +# Each database also has base//PG_VERSION; only cluster markers determine layout. +for version in $(find /var/lib/postgresql -name PG_VERSION -type f ! -path '*/base/*'); do if [ "$version" != "$PGDATA/PG_VERSION" ] || [ "$(cat "$version")" != 18 ]; then echo 'Existing PostgreSQL layout/version differs from PGDATA; refusing initialization' >&2 exit 1 diff --git a/infrastructure/postgresql/import-identity.sql b/infrastructure/postgresql/import-identity.sql index 5e880c9..a6c9573 100644 --- a/infrastructure/postgresql/import-identity.sql +++ b/infrastructure/postgresql/import-identity.sql @@ -3,11 +3,11 @@ -- Timestamps require an explicit UTC offset. PostgreSQL stores microsecond precision. SET LOCAL search_path TO payguard; SET LOCAL statement_timeout = '5min'; -LOCK TABLE identity_challenge, identity_audit_log, identity_challenge_scope IN ACCESS EXCLUSIVE MODE; +LOCK TABLE identity_otp, identity_audit_log, identity_otp_scope, identity_outbox IN ACCESS EXCLUSIVE MODE; DO $guard$ BEGIN - IF EXISTS (SELECT 1 FROM identity_challenge) OR EXISTS (SELECT 1 FROM identity_audit_log) - OR EXISTS (SELECT 1 FROM identity_challenge_scope) + IF EXISTS (SELECT 1 FROM identity_otp) OR EXISTS (SELECT 1 FROM identity_audit_log) + OR EXISTS (SELECT 1 FROM identity_otp_scope) OR EXISTS (SELECT 1 FROM identity_outbox) THEN RAISE EXCEPTION 'Identity import destination is populated'; END IF; END $guard$; @@ -38,25 +38,25 @@ BEGIN ) THEN RAISE EXCEPTION 'Audit timestamp offset required'; END IF; END $guard$; -INSERT INTO identity_challenge +INSERT INTO identity_otp (id,user_id,purpose,channel,verification_mode,code_hash,device_binding_hash,attempts,max_attempts,status,created_at,expires_at,consumed_at,retention_until) SELECT CASE WHEN c->>'id' ~ '^otp_[A-Za-z0-9]{22}$' THEN c->>'id' ELSE mapping->'challenges'->>(c->>'id') END, CASE WHEN c->>'user_id' ~ '^cus_[A-Za-z0-9]{22}$' THEN c->>'user_id' ELSE mapping->'customers'->>(c->>'user_id') END, c->>'purpose',c->>'channel',c->>'verification_mode',c->>'code_hash',c->>'device_binding_hash', - (c->>'attempts')::integer,(c->>'max_attempts')::integer,c->>'status', + (c->>'attempts')::integer,(c->>'max_attempts')::integer,(c->>'status')::identity_status, regexp_replace(c->>'created_at', '(\.[0-9]{6})[0-9]+', '\1')::timestamptz,regexp_replace(c->>'expires_at', '(\.[0-9]{6})[0-9]+', '\1')::timestamptz,regexp_replace(c->>'consumed_at', '(\.[0-9]{6})[0-9]+', '\1')::timestamptz,regexp_replace(c->>'retention_until', '(\.[0-9]{6})[0-9]+', '\1')::timestamptz FROM pg_temp.import_document, LATERAL jsonb_array_elements(source->'challenges') c; -INSERT INTO identity_audit_log (id,challenge_id,user_id,action,reason,source_ip,occurred_at) +INSERT INTO identity_audit_log (id,otp_id,user_id,action,reason,source_ip,occurred_at) SELECT (a->>'id')::uuid, CASE WHEN a->>'challenge_id' ~ '^otp_[A-Za-z0-9]{22}$' THEN a->>'challenge_id' ELSE mapping->'challenges'->>(a->>'challenge_id') END, CASE WHEN a->>'user_id' ~ '^cus_[A-Za-z0-9]{22}$' THEN a->>'user_id' ELSE mapping->'customers'->>(a->>'user_id') END, a->>'action',a->>'reason',a->>'source_ip',regexp_replace(a->>'occurred_at', '(\.[0-9]{6})[0-9]+', '\1')::timestamptz FROM pg_temp.import_document, LATERAL jsonb_array_elements(source->'audit') a; -INSERT INTO identity_challenge_scope (user_id,purpose) SELECT DISTINCT user_id,purpose FROM identity_challenge; +INSERT INTO identity_otp_scope (user_id,purpose) SELECT DISTINCT user_id,purpose FROM identity_otp; DO $reconcile$ BEGIN - IF EXISTS (SELECT 1 FROM identity_audit_log a JOIN identity_challenge c ON c.id=a.challenge_id WHERE a.user_id<>c.user_id) - OR (SELECT count(*) FROM identity_challenge)<>(SELECT jsonb_array_length(source->'challenges') FROM pg_temp.import_document) + IF EXISTS (SELECT 1 FROM identity_audit_log a JOIN identity_otp c ON c.id=a.otp_id WHERE a.user_id<>c.user_id) + OR (SELECT count(*) FROM identity_otp)<>(SELECT jsonb_array_length(source->'challenges') FROM pg_temp.import_document) OR (SELECT count(*) FROM identity_audit_log)<>(SELECT jsonb_array_length(source->'audit') FROM pg_temp.import_document) THEN RAISE EXCEPTION 'Identity import reconciliation failed'; END IF; END diff --git a/payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql b/payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql deleted file mode 100644 index dd4793e..0000000 --- a/payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE collateral (id RAW(16) PRIMARY KEY, owner_token VARCHAR2(128) NOT NULL, loan_id RAW(16) NOT NULL, amount NUMBER(28,8) NOT NULL, asset_currency CHAR(3) NOT NULL, warning_ltv NUMBER(10,8) NOT NULL, margin_ltv NUMBER(10,8) NOT NULL, liquidation_ltv NUMBER(10,8) NOT NULL, status VARCHAR2(24) NOT NULL, version NUMBER(19) DEFAULT 0 NOT NULL, retention_until TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL); -CREATE INDEX ix_collateral_loan_status ON collateral(loan_id, status); -CREATE TABLE collateral_valuation (id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, collateral_id RAW(16) NOT NULL, asset_value NUMBER(28,8) NOT NULL, fx_rate NUMBER(28,12) NOT NULL, observed_at TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT fk_val_collateral FOREIGN KEY(collateral_id) REFERENCES collateral(id)); -CREATE INDEX ix_collateral_val_time ON collateral_valuation(collateral_id, observed_at); -CREATE TABLE collateral_audit_log (id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, collateral_id RAW(16) NOT NULL, subject_token VARCHAR2(128), actor_token VARCHAR2(128), action VARCHAR2(64) NOT NULL, reason VARCHAR2(256), occurred_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, previous_hash VARCHAR2(128), record_hash VARCHAR2(128) NOT NULL); -CREATE INDEX ix_collateral_audit_subject_time ON collateral_audit_log(subject_token, occurred_at); -CREATE TABLE collateral_processed_event (event_id RAW(16) NOT NULL, consumer_name VARCHAR2(128) NOT NULL, processed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, PRIMARY KEY(event_id, consumer_name)); -CREATE TABLE collateral_outbox (id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, event_id RAW(16) UNIQUE NOT NULL, event_type VARCHAR2(128) NOT NULL, aggregate_id VARCHAR2(128) NOT NULL, payload CLOB NOT NULL, occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, published_at TIMESTAMP WITH TIME ZONE); diff --git a/payguard-collateral-service/Dockerfile b/payguard-collateral/Dockerfile similarity index 100% rename from payguard-collateral-service/Dockerfile rename to payguard-collateral/Dockerfile diff --git a/payguard-collateral-service/README.md b/payguard-collateral/README.md similarity index 83% rename from payguard-collateral-service/README.md rename to payguard-collateral/README.md index 0c98a6e..665cce5 100644 --- a/payguard-collateral-service/README.md +++ b/payguard-collateral/README.md @@ -10,4 +10,4 @@ From the repository root with JDK 25 and Docker: ./mvnw -B -ntp -pl payguard-collateral-service -am test ``` -The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Original Oracle migrations remain unchanged under `src/main/resources/db/legacy/oracle` for authorized historical migration only. Runtime database passwords do not belong in `.env`. +The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Runtime database passwords do not belong in `.env`. diff --git a/payguard-loan-service/pom.xml b/payguard-collateral/pom.xml similarity index 98% rename from payguard-loan-service/pom.xml rename to payguard-collateral/pom.xml index 42ff186..840544e 100644 --- a/payguard-loan-service/pom.xml +++ b/payguard-collateral/pom.xml @@ -10,7 +10,7 @@ 1.0.0-SNAPSHOT - payguard-loan-service + payguard-collateral @@ -22,23 +22,14 @@ flyway-database-postgresql test - - dev.amg.payguard - payguard-shared - ${project.version} - org.springframework.boot - spring-boot-starter-webmvc + spring-boot-starter-data-jpa org.springframework.boot spring-boot-starter-kafka - - org.springframework.boot - spring-boot-starter-data-jpa - org.springframework.boot spring-boot-starter-flyway @@ -49,6 +40,15 @@ postgresql runtime + + dev.amg.payguard + payguard-shared + ${project.version} + + + org.springframework.boot + spring-boot-starter-webmvc + org.springframework.boot spring-boot-starter-webmvc-test diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/CollateralServiceApplication.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/CollateralServiceApplication.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/CollateralServiceApplication.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/CollateralServiceApplication.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralApplicationService.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralDataExportPort.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralLiquidationPort.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/CollateralRepository.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/application/SubjectTokenVault.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/Collateral.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/CollateralStatus.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/PriceOracle.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/domain/Valuation.java diff --git a/payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/infrastructure/CollateralKafkaConfiguration.java b/payguard-collateral/src/main/java/dev/amg/payguard/collateral/infrastructure/CollateralKafkaConfiguration.java similarity index 100% rename from payguard-collateral-service/src/main/java/dev/amg/payguard/collateral/infrastructure/CollateralKafkaConfiguration.java rename to payguard-collateral/src/main/java/dev/amg/payguard/collateral/infrastructure/CollateralKafkaConfiguration.java diff --git a/payguard-collateral-service/src/main/resources/application.yaml b/payguard-collateral/src/main/resources/application.yaml similarity index 99% rename from payguard-collateral-service/src/main/resources/application.yaml rename to payguard-collateral/src/main/resources/application.yaml index d55b682..5458f64 100644 --- a/payguard-collateral-service/src/main/resources/application.yaml +++ b/payguard-collateral/src/main/resources/application.yaml @@ -18,14 +18,12 @@ spring: ddl-auto: validate flyway: enabled: false - payguard: vault: enabled: true address: ${VAULT_ADDR} ca-file: ${VAULT_CA_FILE} token-file: ${VAULT_TOKEN_FILE} - management: endpoint: health: @@ -38,8 +36,6 @@ management: web: exposure: include: health,info - server: shutdown: graceful - spring.lifecycle.timeout-per-shutdown-phase: 20s diff --git a/payguard-collateral-service/src/main/resources/banner.txt b/payguard-collateral/src/main/resources/banner.txt similarity index 100% rename from payguard-collateral-service/src/main/resources/banner.txt rename to payguard-collateral/src/main/resources/banner.txt diff --git a/payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql b/payguard-collateral/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql similarity index 100% rename from payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql rename to payguard-collateral/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql diff --git a/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java b/payguard-collateral/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java similarity index 100% rename from payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java rename to payguard-collateral/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java diff --git a/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java b/payguard-collateral/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java similarity index 100% rename from payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java rename to payguard-collateral/src/test/java/dev/amg/payguard/collateral/domain/CollateralDomainTest.java diff --git a/payguard-collateral-service/src/test/resources/application-test.yaml b/payguard-collateral/src/test/resources/application-test.yaml similarity index 100% rename from payguard-collateral-service/src/test/resources/application-test.yaml rename to payguard-collateral/src/test/resources/application-test.yaml diff --git a/payguard-collateral-service/src/test/resources/postgres-test-init.sql b/payguard-collateral/src/test/resources/postgres-test-init.sql similarity index 100% rename from payguard-collateral-service/src/test/resources/postgres-test-init.sql rename to payguard-collateral/src/test/resources/postgres-test-init.sql diff --git a/payguard-api-gateway/pom.xml b/payguard-gateway/pom.xml similarity index 98% rename from payguard-api-gateway/pom.xml rename to payguard-gateway/pom.xml index 86f9e7c..8b07a39 100644 --- a/payguard-api-gateway/pom.xml +++ b/payguard-gateway/pom.xml @@ -10,7 +10,7 @@ 1.0.0-SNAPSHOT - payguard-api-gateway + payguard-gateway diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/ApiGatewayApplication.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/ApiGatewayApplication.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/ApiGatewayApplication.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/ApiGatewayApplication.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/CollateralGatewayClient.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/CollateralGatewayClient.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/CollateralGatewayClient.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/CollateralGatewayClient.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/LoanGatewayClient.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/LoanGatewayClient.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/LoanGatewayClient.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/LoanGatewayClient.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/WalletGatewayClient.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/WalletGatewayClient.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/client/WalletGatewayClient.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/client/WalletGatewayClient.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/config/GatewayProperties.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/config/GatewayProperties.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/config/GatewayProperties.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/config/GatewayProperties.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/config/GrpcClientConfiguration.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/config/GrpcClientConfiguration.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/config/GrpcClientConfiguration.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/config/GrpcClientConfiguration.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/security/GatewaySecurityConfiguration.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/security/GatewaySecurityConfiguration.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/security/GatewaySecurityConfiguration.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/security/GatewaySecurityConfiguration.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/CollateralController.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/CollateralController.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/CollateralController.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/CollateralController.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/CorrelationIdFilter.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/CorrelationIdFilter.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/CorrelationIdFilter.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/CorrelationIdFilter.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayExceptionHandler.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayExceptionHandler.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayExceptionHandler.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayExceptionHandler.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayJson.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayJson.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayJson.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayJson.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactory.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactory.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactory.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactory.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewaySerializationException.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewaySerializationException.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewaySerializationException.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/GatewaySerializationException.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/LoanController.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/LoanController.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/LoanController.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/LoanController.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletController.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletController.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletController.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletController.java diff --git a/payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletRequests.java b/payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletRequests.java similarity index 100% rename from payguard-api-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletRequests.java rename to payguard-gateway/src/main/java/dev/amg/payguard/gateway/web/WalletRequests.java diff --git a/payguard-api-gateway/src/main/resources/application.yaml b/payguard-gateway/src/main/resources/application.yaml similarity index 100% rename from payguard-api-gateway/src/main/resources/application.yaml rename to payguard-gateway/src/main/resources/application.yaml diff --git a/payguard-api-gateway/src/main/resources/banner.txt b/payguard-gateway/src/main/resources/banner.txt similarity index 100% rename from payguard-api-gateway/src/main/resources/banner.txt rename to payguard-gateway/src/main/resources/banner.txt diff --git a/payguard-api-gateway/src/test/java/dev/amg/payguard/gateway/ApiGatewayApplicationTests.java b/payguard-gateway/src/test/java/dev/amg/payguard/gateway/ApiGatewayApplicationTests.java similarity index 100% rename from payguard-api-gateway/src/test/java/dev/amg/payguard/gateway/ApiGatewayApplicationTests.java rename to payguard-gateway/src/test/java/dev/amg/payguard/gateway/ApiGatewayApplicationTests.java diff --git a/payguard-api-gateway/src/test/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactoryTest.java b/payguard-gateway/src/test/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactoryTest.java similarity index 100% rename from payguard-api-gateway/src/test/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactoryTest.java rename to payguard-gateway/src/test/java/dev/amg/payguard/gateway/web/GatewayRequestContextFactoryTest.java diff --git a/payguard-identity-service/ARCHITECTURE.md b/payguard-identity-service/ARCHITECTURE.md deleted file mode 100644 index 4344fcc..0000000 --- a/payguard-identity-service/ARCHITECTURE.md +++ /dev/null @@ -1,712 +0,0 @@ -# PayGuard Identity Service Architecture - -**Scope:** `payguard-identity-service`\ -**Root package:** `dev.amg.payguard.identity`\ -**Status:** Target architecture; implementation and migration in progress\ -**Last updated:** 2026-10-10 - -> Identity separates authentication integration, access policy, customer identity, -> verification, and registration. These responsibilities do not all have the same -> owner, and successful verification is not permission to execute a financial action. - -## Contents - -1. [Document scope and implementation status](#1-document-scope-and-implementation-status) -2. [Architectural decisions](#2-architectural-decisions) -3. [Module ownership](#3-module-ownership) -4. [Package structure and layer responsibilities](#4-package-structure-and-layer-responsibilities) -5. [Module contracts and dependency rules](#5-module-contracts-and-dependency-rules) -6. [Identity model and typed identifiers](#6-identity-model-and-typed-identifiers) -7. [Authentication and authorization boundaries](#7-authentication-and-authorization-boundaries) -8. [Verification model and state transitions](#8-verification-model-and-state-transitions) -9. [Application workflows](#9-application-workflows) -10. [Persistence, transactions, and concurrency](#10-persistence-transactions-and-concurrency) -11. [Security, privacy, and operations](#11-security-privacy-and-operations) -12. [Testing and architectural enforcement](#12-testing-and-architectural-enforcement) -13. [Migration and implementation order](#13-migration-and-implementation-order) -14. [Open decisions and extraction criteria](#14-open-decisions-and-extraction-criteria) -15. [References](#15-references) - -## 1. Document scope and implementation status - -This document is the implementation guide for Identity, not a description of the -entire PayGuard platform. Other services are mentioned only to define Identity's -boundaries. It contains no claim about Revolut's or Stripe's internal architecture. - -The Verification baseline was inspected in the current working tree for this -refactoring. Other modules remain target responsibilities unless stated otherwise. -Update this file when implementation changes. - -| Area | Current implementation status | -| --- | --- | -| OTP aggregate | Complete immutable-snapshot transitions retained; final success/failure and exception-safety regression tests pass. | -| Typed identifiers | `issue()`/`restore()`, use cases, audit, delivery, and repository use `OtpId`/`CustomerId`; inbound and persistence adapters parse strings. The inverted prefix guard was corrected without changing the specified formats or equality. | -| Package migration | Verification's packages, imports, adapter wiring, and test packages corrected. Existing IDs remain in `customer.domain.value_object`; final exported-ID ownership is still open. | -| Required outbound contracts | `OtpRepository` in Domain; delivery, audit, request quota, and trusted TOTP verification in Application. | -| Step-up tokens | Retired by explicit developer decision: issuance, Redis adapter, consumption endpoint, and token TTL removed. Verification returns a completion result, not an authorization credential. | -| Persistence | PostgreSQL JPA adapters preserve prefixed strings and UUID audit IDs. Row locks serialize verification; durable scope rows and a partial unique index serialize issuance. Historical Oracle V2 still refuses unmapped populated legacy tables. | -| Keycloak, access/customer/registration workflows | Architectural direction only; not implemented by this refactoring. | -| Concurrency, durable evidence, idempotency | Still unresolved; no distributed single-use or exactly-once claim. | - - -**Established decisions** below summarize the agreed direction. **Proposed -conventions** make that direction implementable but still require review. **Open -decisions** must not be silently treated as completed design. - -## 2. Architectural decisions - -Identity is one deployable Java/Spring Boot service organized around four business -modules: `access`, `customer`, `verification`, and `registration`. Each module owns -its rules and state. Internal calls normally use Java application contracts, not -HTTP or gRPC between modules in the same process. - -The previous names `account` and `onboarding` are replaced by `access` and -`registration`. An Account remains a valid domain concept inside Access; it means -an internal login/access identity, not a financial ledger account. - -| Decision | Reason | Accepted cost | -|------------------------------------------|-----------------------------------------------------------------|----------------------------------------------------------------| -| Business modules before technical layers | Make responsibility and change ownership visible. | Contracts, mapping, and boundary tests require effort. | -| One deployment initially | Independent deployment is not yet justified by measured needs. | Shared releases, resources, and process failure boundary. | -| Keycloak owns authentication | Adopt established identity protocols and credential mechanisms. | PayGuard must operate and integrate the provider reliably. | -| PayGuard owns business authorization | Permissions alone cannot decide every resource-specific action. | Services must enforce explicit, tested policies. | -| Typed, prefixed domain identifiers | Prevent accidental identifier substitution and validate format. | Boundary conversions and data migration are required. | -| Verification produces bounded evidence | Challenge completion and business permission are distinct. | Consumers must validate evidence and their own business rules. | - -This structure does not replace DDD, prove that four bounded contexts exist, or -make a layered alternative incorrect. It is a way to express the chosen ownership -boundaries. Package names alone do not enforce them. - -A shared process is neither an automatic transaction nor a security isolation -boundary. Complete-state assignment is not distributed atomicity. Those guarantees -require separate mechanisms and tests. - -## 3. Module ownership - -### 3.1 Access - -**Question:** Which internal account is acting, and what access relationships and -permissions does it have? - -Access owns internal account identity and access status, external identity links, -tenant memberships, invitations, roles, and permission assignments. Tenant access -administration belongs here initially; a broader tenant/business-organization -lifecycle may justify a separate module later. - -Key operations include resolving an external identity, suspending access, managing -membership, and evaluating access permissions. Other modules may request these -operations through Access's public contract. - -Access does not store PayGuard login passwords, implement WebAuthn verification, -manage customer profiles, or decide whether funds can be withdrawn. It does not -need a Customer record for every account: an organizational employee can have -membership without being a financial customer. - -### 3.2 Customer - -**Question:** Which stable PayGuard business customer does this record represent? - -Customer owns `CustomerId`, the customer record, its lifecycle, and its explicit -relationship to internal access accounts. Proposed lifecycle terms include -`PENDING`, `ACTIVE`, `SUSPENDED`, and `CLOSED`; the final transition rules require -implementation-specific decisions. - -Changing a login provider or email must not regenerate the customer identifier. -Suspending access must not implicitly delete customer or financial records. - -Customer owns neither credentials nor OTP state. Legal identity-checking providers -and their detailed workflows are outside this document; Customer may later keep -references to approved outcomes without implementing the checking mechanism. - -### 3.3 Verification - -**Question:** Has this challenge been completed for its intended subject and purpose? - -Verification owns OTP challenges, expiration, attempt accounting, consumption, -invalidation, verification audit entries, and the planned verification-evidence -lifecycle. Its adapters integrate with hashing, delivery, persistence, and any -explicitly approved verification provider. - -Verification does not complete registration, activate a customer, or authorize a -financial action on its own. It does not implement every authentication mechanism -supported by Keycloak. - -A challenge subject is not necessarily an existing customer. Registration and -account-recovery subjects require an explicit contract before those flows ship. - -### 3.4 Registration - -**Question:** Which required registration steps have completed, and what remains? - -Registration owns the registration identifier, progress, completion criteria, -expiry, and retry/recovery coordination. It requests behavior from Access, -Customer, and Verification through their contracts. - -It must not write their tables, modify their aggregates, or bypass their rules. -A verified contact is input to registration completion, not completion itself. -Customer activation and access grants remain decisions of their respective owners. - -Give Registration a domain model when its states and rules justify one. Do not -build empty abstractions only to mirror another module's directory structure. - -## 4. Package structure and layer responsibilities - -The following is a **target structure**, not an assertion that these directories -already exist. The `api` convention is proposed for explicit internal contracts. - -```text -payguard-identity-service/ - ARCHITECTURE.md - src/main/java/dev/amg/payguard/identity/ - access/ - api/ - domain/ - application/ - infrastructure/ - keycloak/ - interfaces/ - customer/ - api/ - domain/ - application/ - infrastructure/ - interfaces/ - verification/ - api/ - domain/ - application/ - infrastructure/ - interfaces/ - registration/ - api/ - domain/ - application/ - infrastructure/ - interfaces/ - shared/ - domain/ - identifier/ -``` - -`api` is not another business layer. It is the small exported surface of a module. -`interfaces` contains transport adapters. They solve different problems. - -| Package | Responsibility | Examples | Must not do | -| --- | --- | --- | --- | -| `api` | Publish internal application contracts and immutable results. | Request verification; ensure customer exists. | Expose repositories, builders, provider SDK types, or mutable aggregates. | -| `domain` | Define local business rules and valid state. | `OTP`, `OtpSnapshot`, policies, value objects. | Depend on Spring, SQL implementations, HTTP, or another module's implementation. | -| `application` | Execute a use case and coordinate dependencies. | Load OTP, invoke `verify`, persist the result. | Reimplement attempt counting or lifecycle rules already owned by the aggregate. | -| `infrastructure` | Implement outbound ports and technical integration. | PostgreSQL JPA repository, Vault adapter, Redis adapter, delivery client. | Become the owner of business eligibility rules. | -| `interfaces` | Receive external requests and map results/errors. | REST controllers, gRPC endpoints, message consumers. | Mutate domain state directly or trust unverified caller identity fields. | -| `shared/domain` | Hold deliberately shared, framework-free primitives. | `AggregateRoot`, `ValueObject`. | Contain workflows, repositories, or miscellaneous code without an owner. | - -A port is an interface defined on the inside of a dependency boundary. Its adapter -is the external implementation. An aggregate repository contract may live in its -own domain; an application-only delivery or provider port may live in application. -Do not move every interface into Domain just because it is an interface. - -Within a module, Interfaces calls the application contract, Application invokes -Domain and ports, and Infrastructure implements ports. Runtime dependency injection -connects them; Domain does not import Infrastructure. - -Bootstrap and security wiring are composition concerns, not additional business -modules. Configure module detection intentionally rather than treating every -technical package as a capability. - -### Where existing classes belong - -| Class or concern | Owner / placement | -| --- | --- | -| `OTP`, `OtpSnapshot` | `verification.domain.model` | -| `OtpVerificationPolicy` and OTP enums | Verification domain | -| `OtpId`, `OtpAuditId` | Verification-owned value objects | -| `CustomerId` | Customer-owned value object | -| `OtpVerificationCommand` | Verification-owned contract; its exact layer depends on whether it is an application request or domain operation input. | -| `PrefixedIdValidator` | Customer domain value-object package; exact prefix and ASCII suffix validation | -| Keycloak API client implementation | `access.infrastructure.keycloak` | -| HTTP JWT security configuration | Service composition / inbound security, not a domain aggregate | - -Keep the current `value_object` spelling during this migration. A naming-style -cleanup must not be mixed into behavioral changes. - -## 5. Module contracts and dependency rules - -**Baseline rules:** no circular module dependencies, no imports of another module's -persistence implementation, and no cross-module aggregate mutation. A module -exposes outcomes and operations, not its entire model. - -The following capability-call direction is the proposed baseline: - -| Caller | Allowed destination | Purpose | -| --- | --- | --- | -| Registration | Access, Customer, Verification APIs | Coordinate registration steps. | -| Customer | Access API, only where required | Establish or validate an account relationship. | -| Verification | Access API, only where required | Resolve trusted actor/access context for protected operations. | -| Access | No other Identity capability by default | Keep access identity independent from customer/registration workflows. | -| Any module | Approved shared primitives | Reuse pure validation or deliberately shared contracts. | - -Do not add a reverse call for convenience. For example, Verification returns a -verification outcome; it does not call Registration to complete registration. -A consumer may depend on an event contract published by its owner, but asynchronous -communication does not excuse circular type dependencies or an undocumented flow. - -A type reference is also a dependency. Verification may need an exported -`CustomerId` without permission to call Customer repositories. Export only the -required identity contract, not a whole domain package. - -**Open contract placement:** the final exported-ID package arrangement must be -settled before architecture rules are enforced. In particular, making Verification -import `RegistrationId` from Registration while Registration depends on Verification -would create a cycle. Resolve this with an explicitly reviewed pure identity -contract or a Verification-owned subject/reference contract mapped by the caller. -Do not solve it with an unrestricted raw `String` or by opening both modules. - -For Spring Modulith, a package called `api` is not automatically an exported named -interface. Its default API is the module base package; additional exports require -explicit configuration. Configure the chosen convention rather than relying on -its name. [Module fundamentals][4] - -## 6. Identity model and typed identifiers - -### 6.1 Concepts that must remain distinct - -| Concept | Meaning | Owner | -| --- | --- | --- | -| External login identity | Trusted issuer and subject from the configured provider. | Keycloak; linked internally by Access. | -| Account | Stable internal access identity and access status. | Access | -| Customer | Person or business receiving PayGuard services. | Customer | -| Tenant | Organizational access/resource-isolation scope. | Access initially, subject to lifecycle review. | -| Membership | An account's relationship and permissions within a tenant. | Access | -| Registration | An unfinished or completed registration workflow. | Registration | -| Service principal | A machine identity, not a human customer session. | Provider integration and Access policy | - -Customer relationships and tenant memberships branch from an internal account; -they are not a mandatory account-to-customer-to-membership chain. Cardinalities -and uniqueness constraints must match the chosen product model, not be inferred -from this table. - -OpenID Connect specifies issuer and subject together as the stable provider -identity reference. Email is not an equivalent stable identifier. Provider changes -and account linking require an explicit, verified workflow. [OIDC Core][3] - -### 6.2 Identifier contract - -These are PayGuard conventions inspired by prefixed public IDs, not claims about -Stripe's internal generation rules. - -| Value object | Prefix | Total ASCII length | Generation owner | -| --- | --- | --- | --- | -| `AccountId` | `acc_` | 26 | Access | -| `CustomerId` | `cus_` | 26 | Customer | -| `OtpId` | `otp_` | 26 | Verification | -| `OtpAuditId` | `oaud_` | 27 | Verification | -| `RegistrationId` | `reg_` | 26 | Registration | -| `TenantId` | `ten_` | 26 | Access initially | -| `MembershipId` | `mem_` | 26 | Access | - -Every suffix has exactly 22 case-sensitive ASCII Base62 characters: `A-Z`, `a-z`, -and `0-9`. Reject nulls, incorrect prefixes, incorrect lengths, whitespace, and -all other characters. Do not trim, normalize, or change case. - -Value objects are immutable `final` classes with private constructors, a -`parse(String)` factory, `value()`, and type-specific value equality/hash codes. -Constructor validation must prevent invalid construction; parsing does not generate -an identity or contact a database. - -A separate generator supplies new IDs to the owning module. Use a cryptographically -secure source such as Java `SecureRandom` and an unbiased alphabet-selection -procedure. A random suffix is not an absolute uniqueness guarantee. [Java API][8] - -Persistence must enforce uniqueness and preserve case-sensitive comparison -semantics. JSON, message payloads, database columns, and Redis serialization use -strings at their boundaries; internal application/domain contracts use typed IDs. - -`CustomerId` parsing establishes format, not existence, ownership, or permission. -Identifiers must contain no customer information and must not serve as credentials. - -### 6.3 Current migration rule - -Both `OTP.issue()` and `OTP.restore()` must accept typed identifiers. Convert raw -incoming values in boundary adapters, not inside the aggregate. Repository adapters -reconstitute typed values when reading persisted representations. - -Verification never generates a new customer identifier for each challenge. It -receives the existing relationship, or an explicitly supported non-customer subject. - -## 7. Authentication and authorization boundaries - -Keycloak is the selected authentication provider. Its supported capabilities include -credentials, sessions, authentication flows, and WebAuthn/passkeys; deployment and -configuration remain PayGuard's operational responsibility. [Keycloak guide][1] - -An Access-to-Keycloak adapter is needed for actual provider API operations, not to -proxy every login through Identity. Domain types must not expose provider SDK types. -Login can take place through the provider's standard client flow. - -The target inbound security contract requires appropriate access-token validation: -trusted issuer, intended audience, accepted algorithm, signature, and time claims. -Spring Security provides JWT resource-server mechanisms; explicitly configure the -checks against the versions pinned in the build. An ID token is not the API bearer -credential. [Resource server documentation][2] [OIDC Core][3] - -PayGuard-specific authorization remains explicit. The selected tenant, actor, -resource, action, membership status, and applicable policy must agree. A supplied -customer ID, tenant ID, or internal-looking header is not trusted identity evidence. -Deny-by-default and request-level authorization are the baseline. [OWASP authorization][6] - -Membership and permission changes require a documented freshness/revocation bound. -Do not assume logout or a role update makes every previously issued, locally -validated JWT immediately unusable. Define cache invalidation, session handling, -and rejection behavior before relying on them. - -**Passkeys:** integrate the provider flow without adding a WebAuthn implementation -to `OTP` or `OtpSnapshot`. Login assurance may contribute to a sensitive-operation -policy, but is not approval of an arbitrary operation. Keycloak supplies the -mechanism; PayGuard defines the meaning of the result. [Keycloak guide][1] - -The existing TOTP verification branch must receive a separate ownership review: -provider-owned login MFA must not silently become a second credential store in -Verification. A caller-supplied boolean is not trusted verification evidence. - -## 8. Verification model and state transitions - -### 8.1 Existing domain contract - -The last supplied snapshot design enforces these rules: - -| Rule | Meaning | -| --- | --- | -| `maxAttempts >= 1` | A challenge permits at least one attempt. | -| `0 <= attempts <= maxAttempts` | Recorded attempts remain within the configured limit. | -| `ACTIVE` implies `attempts < maxAttempts` | An active snapshot is not already exhausted. | -| `expiresAt > createdAt` | The validity interval is nonempty. | -| `USED` if and only if `consumedAt` is present | Consumption state and timestamp agree. | -| `createdAt <= consumedAt < expiresAt` | Consumption falls within the validity interval. | -| Random-numeric mode requires a nonblank hash | Verification data is consistent with the selected mode. | - -Retention must satisfy the existing `OtpRetentionPolicy`; this document does not -invent a retention duration. `ACTIVE` is not sufficient eligibility by itself: -expiry, purpose, and other required context still have to be checked. - -For an eligible attempt, the required completed transitions are: - -| Outcome | Next status | Attempt change | Consumption time | -| --- | --- | --- | --- | -| Wrong code; another attempt remains | `ACTIVE` | Add one | Absent | -| Wrong code; final allowed attempt | `LOCKED` | Add one | Absent | -| Correct code, including the final attempt | `USED` | Add one | Set to the accepted operation time | - -Successful code verification counts as an attempt in this contract. Preliminary -rejections are handled separately; preserve their existing precedence and counting -behavior unless a dedicated contract change explicitly alters them. - -### 8.2 Complete-state construction - -Verification determines eligibility and code outcome, constructs a complete valid -next snapshot, and only then assigns the aggregate field. A helper for final success -must set attempts, status, and consumption time before the snapshot is built. - -Do not build an exhausted `ACTIVE` snapshot and try to repair it afterward. Do not -mutate the aggregate early and rely on a later exception to undo it. - -An exception during hashing or next-state construction must leave the aggregate's -snapshot unchanged. A domain rejection such as an expired challenge can deliberately -produce a valid state change and a normal verification result. - -A local snapshot variable is not mandatory: assigning a successfully constructed -final snapshot directly to the field is valid. The requirement is the completed -transition before assignment, not a variable-naming convention. - -### 8.3 Unresolved input and subject contracts - -The following require explicit decisions, not silent refactoring: handling a supplied -time before creation, required command fields, mode-specific hasher requirements, -preliminary rejection precedence, and trusted TOTP-result provenance. - -The current mandatory `CustomerId` supports existing-customer challenges only. -Before pre-customer registration is supported, define the verification subject, -caller authorization, contact binding, and recovery rules. Do not use a fabricated -customer, a nullable collection of unrelated IDs, or an unrestricted generic ID. -The final Java representation is intentionally not selected in this document. - -### 8.4 Implemented Verification contracts - -- `OtpChallengeRepository.replaceActive(OTP)` invalidates ACTIVE challenges for the - same customer and purpose, then saves the replacement in one adapter transaction. - Channel and destination do not narrow this scope. Persistent customer/purpose scope rows - serialize even first issuance; a partial unique index enforces one ACTIVE row. - `save` inserts only. `withLockedChallenge` restores and mutates the current row under - a PostgreSQL lock, committing before the application throws a business rejection. -- `OtpDelivery.submit(OtpDeliveryRequest)` is an application dependency. The request - carries typed challenge/customer IDs and the actual code for adapter access; its - diagnostics omit the code and destination. `destinationRef` remains a caller - reference with no implemented provider-resolution contract. The mock discards - codes; SMS/email/push modes now throw explicitly because their provider integrations - are absent. Completion does not mean confirmed customer receipt. -- `VerificationAudit.record(VerificationAuditEntry)` preserves synchronous audit - persistence and propagated failures. The entry contains a typed challenge and - customer subject, closed action set (`ISSUED`, `VERIFY`), reason code, source IP, - and timestamp. No trusted actor or correlation context is currently supplied; - the subject is not labeled an authenticated actor. Audit commits separately from - challenge state, so an audit failure can follow committed consumption. -- `VerificationQuota.tryAcquire(Request)` consumes quota for ISSUE or VERIFY, - scoped by customer, purpose, and source IP. The existing Redis INCR and first-use - EXPIRE behavior, limits, key format, and propagated storage errors are preserved. - INCR is atomic; the two-command increment/expiry sequence is not atomic. There - is no new gateway rate limiter, and per-challenge attempts are a separate control. -- `TotpVerification` verifies codes server-side via the existing trusted secret - port. The HTTP request cannot supply `authenticatorCodeValid`. Missing secrets - return absence; a denied or unavailable Vault dependency throws before code-check - mutation. The production adapter reads customer-scoped KV v2 seeds; enrollment and - recovery remain absent. LOGIN ownership conflicts with - the intended provider-owned MFA and still requires a decision. -- `OtpCodeHasher` remains the single active hash/match contract. No `CodeHasher` - exists in the inspected tree. Salted HMAC-SHA256 now stores `keyId:salt:digest`; - old `salt:digest` requires an explicitly selected legacy key. Vault key refresh runs - outside persistence locks and preserves the configured OTP compatibility window. - -The current use case supports customer subjects only. Registration/account subjects, -trusted caller authentication/authorization, operation-bound evidence, and retryable -provider delivery are not implemented. A successful response reports `challengeId`, -`purpose`, and `verifiedAt`; it is not a transferable proof for a financial action. - -The domain's existing minimum retention is three calendar years after expiry. -The application/resource defaults now use 1097 days from issuance to accommodate -that invariant for the default challenge lifetimes. Explicit shorter configurations -still fail validation. No purge scheduler is implemented here. - -## 9. Application workflows - -### 9.1 Existing-customer challenge verification - -The inbound adapter validates request shape and parses IDs. The application use case -establishes trusted actor context, loads the challenge, and checks that the caller -may operate on that subject. The aggregate evaluates its own verification rules. -The application persists the resulting state under the chosen concurrency contract, -then returns the committed outcome through the adapter. - -Do not report a successful consumption before its required persistence succeeds. -Do not return the full internal snapshot as a public response: it contains internal -verification material such as hashes. - -### 9.2 Registration - -A candidate product flow is: start a pending registration, request contact -verification, record the matching completed verification, ensure the required -Access account and Customer relationship exist, and mark registration complete. - -The precise order of provider identity creation, internal account creation, and -contact verification is an open product/security decision. Public pre-login endpoints -need an explicit registration-session or equivalent trusted-flow contract; a public -`reg_...` identifier alone must not authorize progression or account linking. - -Registration consumes Verification's result and applies its own completion rules. -It must bind the result to the same registration and contact; changing the contact -invalidates the previous proof for that step. - -Retries must recover the existing logical registration/account/customer relationship. -A committed finalization followed by a lost HTTP response is not permission to create -another customer. For separately committed steps, persist progress and reconcile -completed effects after a crash. - -### 9.3 Login and internal identity resolution - -The client authenticates through Keycloak. Protected Identity endpoints validate the -appropriate credential and Access resolves the trusted provider identity to the -internal account. Customer links and tenant memberships are resolved independently -as required by the use case. - -An unknown provider identity follows an explicit provisioning or rejection policy; -it must not silently become an active customer. Changing a provider link requires -verified account-linking behavior, not email matching alone. - -### 9.4 Verification for a sensitive operation - -The requesting service supplies an authorized, bounded operation context. Verification -issues and completes the relevant challenge and produces or records evidence. The -requesting service validates that evidence and retains the final execution decision. - -Evidence must be bound to its subject, purpose, operation, validity window, and -significant approved terms or an immutable operation version. It must be trusted -and tamper-resistant, and the user must be able to identify the significant terms -being confirmed. Changing those terms invalidates the prior approval. These -requirements follow transaction-authorization guidance. [OWASP transaction authorization][7] - -The evidence representation and consumption protocol are still open decisions. -An identifier alone is not evidence. Challenge consumption and evidence consumption -are separate state changes; neither proves exactly-once execution in another service. - -## 10. Persistence, transactions, and concurrency - -### 10.1 Ownership and transaction scope - -The initial target allows a shared database, with owner-specific tables and migrations. -PostgreSQL/JPA and Vault belong behind infrastructure adapters; domain code must not import generated -records or SQL clients. Repository contracts express required behavior, including -conflict handling, rather than exposing adapter details. - -Local transactions may span selected module operations only through their APIs and -compatible transaction participation. Document those cases. External provider, -delivery, and broker calls are not part of the database transaction. - -Cross-module database constraints require explicit agreement. Prefer owner-supported -queries or dedicated read projections over hidden dependencies on another module's -schema. A shared database must not become unrestricted shared state. - -### 10.2 Competing requests - -Never assume one aggregate instance is safe for concurrent mutation. Loading one -instance per request also does not prevent two requests from updating the same row. - -ADR 0014 selects database row locks for verification and durable scope locks for -issuance. Each verification re-evaluates current state and time after acquiring the -lock. Only one caller receives fresh VERIFIED; competitors receive ALREADY_USED. -Attempts and expiry rejection transitions commit before the HTTP/business exception. -Remote calls remain outside the lock; no quota or external side effect is retried. - -Use concurrency tests with competing requests against the actual persistence adapter. -For one active challenge, only one distinct operation may commit its consumption. -The current API has no stored idempotent verification response; a replay returns -ALREADY_USED rather than a new success. - -### 10.3 Retry and side-effect rules - -An idempotency contract identifies the logical operation, caller scope, payload -compatibility, stored outcome, retention window, and recovery behavior. Database -constraints enforce the uniqueness relationships the contract relies on. - -For durable work that must follow committed state, use a transactional outbox or -another explicitly durable mechanism. Consumers must handle duplicate delivery. -Do not assume an in-memory event or a database commit followed by a best-effort -publish is sufficient. - -Do not hold database locks open while waiting for SMS, email, or Keycloak. Persist -retryable workflow state around external side effects. Expired challenges must not -be revived by a delayed delivery retry. - -## 11. Security, privacy, and operations - -Identity endpoints must enforce resource ownership and tenant membership in addition -to token validity. Apply admission/rate limits to issuing, verifying, and registration -flows; issuing a new challenge must not trivially bypass the intended abuse policy. -Choose rate-limit subjects and storage atomicity in a dedicated implementation ticket. - -Secrets, OTP values, credential hashes, bearer tokens, and step-up tokens must not -appear in ordinary logs. Record only necessary actor references, action, outcome, -correlation context, and policy-relevant audit metadata. Public identifiers remain -potentially linkable information; being non-secret does not justify unrestricted -logging or retention. - -Protect durable delivery data that temporarily needs a code, restrict access, and -remove it when no longer required. Do not persist delivery secrets merely because -an outbox is used. Retention and access rules apply to business data, audit records, -logs, and retry payloads separately. - -Define behavior for missing authorization information, provider outages, unavailable -storage, and exhausted delivery retries. Protected operations must not become -permitted because a dependency failed. Continued use of existing sessions or cached -policy data requires an explicit bounded policy, not an accidental fallback. - -Measure latency, rejection causes, persistence conflicts, provider failures, delivery -retries, and incomplete registration age. Never claim a capacity or availability -level without an appropriate measured workload and failure test. - -## 12. Testing and architectural enforcement - -These are completion requirements, not claims that the tests already exist. - -| Test level | Required evidence | -| --- | --- | -| Identifier unit tests | Valid/invalid prefixes, 21/22/23-character suffixes, null/blank/Unicode inputs, exact case preservation, equality/hash-code contract, distinct ID types. | -| Snapshot and aggregate tests | Final-attempt success/failure, normal failure, expired/terminal behavior, hash/build exceptions preserving old state, restoration rejecting exhausted active state. | -| Application tests | Authorization precedes protected effects; failed persistence cannot return committed success; retry results are consistent. | -| Persistence integration tests | Typed-ID round trips, case-sensitive uniqueness, rollback, competing updates, and idempotency constraints. | -| Boundary tests | Malformed IDs, wrong token issuer/audience/signature/time, spoofed tenant/customer context, no secret-bearing response payloads. | -| Registration integration tests | Duplicate finalization, lost response, crashes between separately committed steps, changed contact, incorrect challenge subject. | -| Architecture tests | Allowed exports only, no cycles, no cross-module repository access, framework-free domain, no provider SDK in public contracts. | - -Spring Modulith's verification can check cycles and internal-package access. Its -allowed-dependency restriction is optional and must be configured. Additional -architecture rules may be needed for the layering and framework constraints above. -[Module verification][5] - -An exception-safe aggregate, a passing unit test, and a passing concurrency test -prove different things. Record test scope and actual results separately. - -## 13. Migration and implementation order - -The following are planning references, not claims that tickets were created in an -external tracker. Keep correctness fixes separate from package moves and new behavior. - -| Order | Work | Completion condition | -| --- | --- | --- | -| 1 | Finish typed issuance (`PG-IDENTITY-109`). | `issue()` and `restore()` accept typed IDs; boundary callers compile; contract tests pass. | -| 2 | Review identifier implementations and tests. | Shared format rules and every ID's equality/validation contract are demonstrated. | -| 3 | Correct ownership imports (`PG-IDENTITY-110`). | `OtpId`/OTP enums belong to Verification; changes preserve behavior. | -| 4 | Decide exported contracts and enforce boundaries. | API/ID exports and allowed dependency graph are explicit and tested. | -| 5 | Complete non-customer subject design (`PG-IDENTITY-111`). | Registration challenges work without fake customers or dependency cycles. | -| 6 | Implement provider integration and account mapping. | Resource-server boundary and trusted identity mapping pass integration tests. | -| 7 | Implement registration/access/customer use cases. | Lifecycle, uniqueness, retry, and membership policies are demonstrated. | -| 8 | Harden persistence and verification evidence. | Concurrent consumption, replay prevention, delivery recovery, and revocation behavior are tested. | - -When replacing UUID challenges or raw external user IDs, choose a compatibility -window or explicit migration. Preserve existing links, outstanding challenges, -Redis keys, and dependent evidence. Never generate a fresh customer identity for -each historical request. Do not deploy incompatible API and persistence formats -without an agreed rollout sequence. - -**Completed refactoring:** typed customer/challenge contracts and Verification -package wiring, PostgreSQL concurrency control and scoped Vault secret adapters. -Keycloak setup, non-customer subjects and operation-bound evidence remain separate work. -See [the implementation guide](../docs/guides/postgresql-vault-migration.md) for exact -versions, role boundaries, migration history, key/pool lifecycle and test evidence. - -## 14. Open decisions and extraction criteria - -| Decision still needed | Must be settled before | -| --- | --- | -| Exported identifier packages and non-customer subject representation | Enforcing module dependencies and shipping registration verification. | -| Account/customer relationship cardinality and tenant lifecycle ownership | Finalizing schemas and uniqueness constraints. | -| Provider provisioning order and registration-session trust | Shipping pre-login registration and account linking. | -| Required verification inputs and pre-creation timestamp handling | Declaring the verification input contract complete. | -| Financial operation idempotency and durable publication | Extending the verified OTP concurrency contract to downstream financial effects. | -| Evidence format, consumer contract, and recovery semantics | Shipping sensitive-operation approval. | -| Token, permission, and cache freshness/revocation limits | Claiming timely account or membership revocation. | -| Outbox/delivery secret handling and retention | Enabling durable asynchronous delivery. | -| Pinned compatible framework/provider versions | Shipping the integration; current documentation is not a version selection. | - -A module becomes a candidate for extraction when measured workload asymmetry, -independent release needs, availability requirements, or security isolation justify -a separate deployment. Extraction must revisit network contracts, authentication, -data ownership, timeouts, retries, and local transaction assumptions. - -Four folders are not evidence that four services are needed. Shared deployment is -not a commitment to remain shared forever. - -Changes to module ownership, public contracts, trust boundaries, and transaction -scope require a reviewed architecture decision and an update to this document. - -## 15. References - -These primary references support the protocol and framework statements. PayGuard's -ownership, packaging, and workflow choices are project design decisions, not claims -that a referenced organization uses this architecture. References checked on -2026-10-10; use documentation compatible with the versions actually pinned in the build. - -1. [Keycloak Server Administration Guide][1] - authentication, sessions, WebAuthn, and passkeys. -2. [Spring Security JWT Resource Server][2] - token validation and audience configuration. -3. [OpenID Connect Core 1.0][3] - identity claims and issuer/subject stability. -4. [Spring Modulith Fundamentals][4] - module APIs, named interfaces, and dependency declarations. -5. [Spring Modulith Structure Verification][5] - architectural verification and its limits. -6. [OWASP Authorization Cheat Sheet][6] - deny-by-default and request-level access checks. -7. [OWASP Transaction Authorization Cheat Sheet][7] - operation binding and confirmation integrity. -8. [Java 21 SecureRandom API][8] - cryptographically strong random generation. - -[1]: https://www.keycloak.org/docs/latest/server_admin/index.html -[2]: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html -[3]: https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability -[4]: https://docs.spring.io/spring-modulith/reference/fundamentals.html -[5]: https://docs.spring.io/spring-modulith/reference/verification.html -[6]: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html -[7]: https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html -[8]: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/security/SecureRandom.html diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java deleted file mode 100644 index 0d7a946..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssueOtpCommand.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; - -public record IssueOtpCommand( - CustomerId customerId, - OtpPurpose purpose, - OtpChannel channel, - String destinationRef, - String deviceFingerprint, - String sourceIp) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java deleted file mode 100644 index b995934..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/IssuedOtp.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import java.time.Instant; - -public record IssuedOtp( - OtpId challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java deleted file mode 100644 index db086f4..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java +++ /dev/null @@ -1,235 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.port.OtpDelivery; -import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; -import dev.amg.payguard.identity.verification.application.port.TotpVerification; -import dev.amg.payguard.identity.verification.application.port.VerificationAudit; -import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; -import dev.amg.payguard.identity.verification.application.port.VerificationQuota; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.event.OtpEvent; -import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; -import dev.amg.payguard.identity.verification.domain.port.OtpRepository; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.util.Objects; -import java.util.function.Supplier; - -public final class OtpApplicationService { - - private final OtpRepository challenges; - private final OtpCodeHasher hasher; - private final OtpCodeGenerator codeGenerator; - private final TotpVerification authenticatorCodeVerifier; - private final OtpDelivery delivery; - private final VerificationQuota rateLimiter; - private final Supplier ids; - private final OtpEventPublisher events; - private final VerificationAudit audit; - private final Clock clock; - private final Duration randomTtl; - private final Duration totpTtl; - private final Duration auditRetention; - private final int maxAttempts; - - public OtpApplicationService( - OtpRepository challenges, - OtpCodeHasher hasher, - OtpCodeGenerator codeGenerator, - TotpVerification authenticatorCodeVerifier, - OtpDelivery delivery, - VerificationQuota rateLimiter, - Supplier ids, - OtpEventPublisher events, - VerificationAudit audit, - Clock clock, - Duration randomTtl, - Duration totpTtl, - Duration auditRetention, - int maxAttempts) { - this.challenges = challenges; - this.hasher = hasher; - this.codeGenerator = codeGenerator; - this.authenticatorCodeVerifier = authenticatorCodeVerifier; - this.delivery = delivery; - this.rateLimiter = rateLimiter; - this.ids = ids; - this.events = events; - this.audit = audit; - this.clock = clock; - this.randomTtl = randomTtl; - this.totpTtl = totpTtl; - this.auditRetention = auditRetention; - this.maxAttempts = maxAttempts; - } - - public IssuedOtp issue(IssueOtpCommand command) { - Objects.requireNonNull(command.customerId(), "customerId must not be null"); - if (!rateLimiter.tryAcquire( - new VerificationQuota.Request( - command.customerId(), - command.purpose(), - command.sourceIp(), - VerificationQuota.Operation.ISSUE))) { - throw new OtpRateLimitExceededException(); - } - Instant now = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - boolean totp = command.channel() == OtpChannel.AUTHENTICATOR_APP; - Duration ttl = totp ? totpTtl : randomTtl; - OtpId challengeId = ids.get(); - String code = totp ? null : codeGenerator.generate(); - OTP challenge = - OTP.issue( - challengeId, - command.customerId(), - new OtpSnapshot( - command.purpose(), - command.channel(), - totp ? OtpVerificationMode.TOTP : OtpVerificationMode.RANDOM_NUMERIC, - code == null ? null : hasher.hash(code), - command.deviceFingerprint() == null - ? null - : hasher.hash(command.deviceFingerprint()), - maxAttempts, - now, - now.plus(ttl), - now.plus(auditRetention), - 0, - OtpStatus.ACTIVE, - null)); - challenges.replaceActive(challenge); - if (!totp) { - delivery.submit( - new OtpDeliveryRequest( - challengeId, - command.customerId(), - command.purpose(), - command.channel(), - command.destinationRef(), - code)); - } - publish( - new OtpEvent( - "identity.challenge-issued", - challengeId, - command.customerId(), - command.purpose(), - command.channel(), - null, - now, - "challenge-issued")); - audit.record( - new VerificationAuditEntry( - challengeId, - command.customerId(), - VerificationAuditEntry.Action.ISSUED, - "challenge-issued", - command.sourceIp(), - now)); - return new IssuedOtp( - challengeId, command.purpose(), command.channel(), challenge.getSnapshot().expiresAt()); - } - - public VerifiedOtp verify(OtpVerificationCommand command) { - OTP challenge = - challenges - .findById(command.challengeId()) - .orElseThrow( - () -> - new dev.amg.payguard.identity.verification.application - .OtpChallengeNotFoundException(command.challengeId())); - if (!challenge.getCustomerId().equals(command.customerId())) { - throw new dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException( - command.challengeId()); - } - if (!rateLimiter.tryAcquire( - new VerificationQuota.Request( - command.customerId(), - command.purpose(), - command.sourceIp(), - VerificationQuota.Operation.VERIFY))) { - throw new OtpRateLimitExceededException(); - } - Instant now = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - boolean authenticatorValid = - challenge.getSnapshot().verificationMode() == OtpVerificationMode.TOTP - && challenge.getSnapshot().status() == OtpStatus.ACTIVE - && command.purpose() == challenge.getSnapshot().purpose() - && now.isBefore(challenge.getSnapshot().expiresAt()) - && authenticatorCodeVerifier.verify(command.customerId(), command.code(), now); - VerificationOutcome outcome = - challenges - .withLockedChallenge( - command.challengeId(), - current -> { - if (!current.getCustomerId().equals(command.customerId())) { - throw new OtpChallengeNotFoundException(command.challengeId()); - } - Instant lockedAt = - clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - OtpVerificationResult currentResult = - current.verify( - new VerifyOtpCommand( - command.purpose(), - command.code(), - command.deviceFingerprint(), - lockedAt, - authenticatorValid), - hasher); - return new VerificationOutcome(current, currentResult, lockedAt); - }) - .orElseThrow(() -> new OtpChallengeNotFoundException(command.challengeId())); - challenge = outcome.challenge(); - now = outcome.at(); - OtpVerificationResult result = outcome.result(); - String reason = result.name().toLowerCase(java.util.Locale.ROOT); - audit.record( - new VerificationAuditEntry( - challenge.getId(), - command.customerId(), - VerificationAuditEntry.Action.VERIFY, - reason, - command.sourceIp(), - now)); - if (result != OtpVerificationResult.VERIFIED) { - publish( - new OtpEvent( - result == OtpVerificationResult.LOCKED ? "identity.locked-out" : "identity.failed", - challenge.getId(), - command.customerId(), - command.purpose(), - challenge.getSnapshot().channel(), - result, - now, - reason)); - throw new dev.amg.payguard.identity.verification.application.OtpVerificationException(result); - } - publish( - new OtpEvent( - "identity.verified", - challenge.getId(), - command.customerId(), - command.purpose(), - challenge.getSnapshot().channel(), - result, - now, - "verified")); - return new VerifiedOtp(challenge.getId(), command.purpose(), now); - } - - private record VerificationOutcome(OTP challenge, OtpVerificationResult result, Instant at) {} - - private void publish(OtpEvent event) { - events.publish(event); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java deleted file mode 100644 index f4095b8..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; - -public record OtpVerificationCommand( - OtpId challengeId, - CustomerId customerId, - OtpPurpose purpose, - String code, - String deviceFingerprint, - String sourceIp) { - @Override - public String toString() { - return "VerifyOtpCommand[challengeId=" + challengeId + ", code=[REDACTED]]"; - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java deleted file mode 100644 index 8ec95c3..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/VerifiedOtp.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import java.time.Instant; - -/** Completion result, not a bearer credential or authorization for a financial operation. */ -public record VerifiedOtp(OtpId challengeId, OtpPurpose purpose, Instant verifiedAt) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java deleted file mode 100644 index 23c0a6d..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/TotpVerification.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.amg.payguard.identity.verification.application.port; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import java.time.Instant; - -/** - * Server-side TOTP verification using a trusted secret source. Retained for the existing challenge - * flow; ownership of LOGIN MFA remains unresolved pending provider integration. - */ -@FunctionalInterface -public interface TotpVerification { - boolean verify(CustomerId customerId, String code, Instant at); -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java deleted file mode 100644 index abab62e..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationQuota.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.amg.payguard.identity.verification.application.port; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; - -@FunctionalInterface -public interface VerificationQuota { - /** - * Consumes one request in the fixed window, including a denied request. Returns false on - * exhausted quota; storage failures propagate. - */ - boolean tryAcquire(Request request); - - record Request(CustomerId customerId, OtpPurpose purpose, String sourceIp, Operation operation) {} - - enum Operation { - ISSUE, - VERIFY - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java deleted file mode 100644 index 9aa029d..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.identity.verification.domain.enums; - -public enum OtpChannel { - SMS, - EMAIL, - PUSH, - AUTHENTICATOR_APP -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java deleted file mode 100644 index b0ea856..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.identity.verification.domain.enums; - -public enum OtpPurpose { - LOGIN, - TRANSFER_CONFIRM, - LOAN_DISBURSE, - COLLATERAL_RELEASE, - CARD_ISSUANCE, - PROFILE_CHANGE -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java deleted file mode 100644 index f396626..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.identity.verification.domain.enums; - -public enum OtpStatus { - ACTIVE, - USED, - EXPIRED, - LOCKED, - INVALIDATED -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java deleted file mode 100644 index 5d44d06..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.amg.payguard.identity.verification.domain.policy; - -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import java.util.Optional; - -public final class OtpVerificationPolicy { - private OtpVerificationPolicy() {} - - /** Guards that reject before an attempt is counted. Empty = eligible to be checked. */ - public static Optional findRejection( - VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { - if (command.purpose() != snapshot.purpose()) { - return Optional.of(OtpVerificationResult.PURPOSE_MISMATCH); - } - return switch (snapshot.status()) { - case USED -> Optional.of(OtpVerificationResult.ALREADY_USED); - case LOCKED -> Optional.of(OtpVerificationResult.LOCKED); - case INVALIDATED -> Optional.of(OtpVerificationResult.INVALIDATED); - case EXPIRED -> Optional.of(OtpVerificationResult.EXPIRED); - case ACTIVE -> evaluateActiveStateGuards(command, snapshot, hasher); - }; - } - - private static Optional evaluateActiveStateGuards( - VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { - if (command.now().isBefore(snapshot.createdAt())) { - throw new IllegalArgumentException("verification time must not precede challenge creation"); - } - if (!command.now().isBefore(snapshot.expiresAt())) { - return Optional.of(OtpVerificationResult.EXPIRED); - } - - if (snapshot.deviceBindingHash() != null) { - var matches = - command.deviceFingerprint() != null - && hasher.matches(command.deviceFingerprint(), snapshot.deviceBindingHash()); - if (!matches) { - return Optional.of(OtpVerificationResult.DEVICE_MISMATCH); - } - } - return Optional.empty(); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java deleted file mode 100644 index bfdf708..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.amg.payguard.identity.verification.domain.value_object; - -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import java.time.Instant; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -public record VerifyOtpCommand( - OtpPurpose purpose, - String code, - @Nullable String deviceFingerprint, - Instant now, - boolean authenticatorCodeValid // Used when verificationMode == TOTP - ) { - @Override - public String toString() { - return "VerifyOTPCommand[purpose=" + purpose + ", code=[REDACTED]]"; - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java deleted file mode 100644 index 58a838f..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java +++ /dev/null @@ -1,88 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.port.OtpRepository; -import java.util.Optional; -import java.util.function.Function; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -@Component -public class JpaOTPRepositoryAdapter implements OtpRepository { - - private final OtpChallengeJpaRepository repository; - private final JdbcTemplate jdbc; - - @jakarta.persistence.PersistenceContext private jakarta.persistence.EntityManager entityManager; - - public JpaOTPRepositoryAdapter(OtpChallengeJpaRepository repository, JdbcTemplate jdbc) { - this.repository = repository; - this.jdbc = jdbc; - } - - @Override - @Transactional - public void replaceActive(OTP replacement) { - String customer = replacement.getCustomerId().getValue(); - String purpose = replacement.getSnapshot().purpose().name(); - jdbc.update( - "INSERT INTO identity_challenge_scope(user_id,purpose) VALUES (?,?) ON CONFLICT DO NOTHING", - customer, - purpose); - jdbc.queryForObject( - "SELECT user_id FROM identity_challenge_scope WHERE user_id=? AND purpose=? FOR UPDATE", - String.class, - customer, - purpose); - repository - .findByUserIdAndPurposeAndStatus( - replacement.getCustomerId().getValue(), - replacement.getSnapshot().purpose(), - OtpStatus.ACTIVE) - .forEach( - entity -> { - OTP challenge = entity.toDomain(); - challenge.invalidate(); - entity.copyFrom(challenge); - repository.save(entity); - }); - repository.flush(); - entityManager.persist(OtpChallengeEntity.from(replacement)); - entityManager.flush(); - } - - @Override - @Transactional - public void save(OTP challenge) { - if (repository.existsById(challenge.getId().value())) { - throw new IllegalStateException("Existing challenges require a locked mutation"); - } - entityManager.persist(OtpChallengeEntity.from(challenge)); - entityManager.flush(); - } - - @Override - @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 10) - public Optional withLockedChallenge(OtpId id, Function mutation) { - return repository - .findLockedById(id.value()) - .map( - entity -> { - OTP current = entity.toDomain(); - T result = mutation.apply(current); - entity.copyFrom(current); - repository.flush(); - return result; - }); - } - - @Override - @Transactional(readOnly = true) - public Optional findById(OtpId challengeId) { - return repository.findById(challengeId.value()).map(OtpChallengeEntity::toDomain); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java deleted file mode 100644 index a182c24..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOtpAuditLogAdapter.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import dev.amg.payguard.identity.verification.application.port.VerificationAudit; -import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -public class JpaOtpAuditLogAdapter implements VerificationAudit { - - private final OtpAuditLogJpaRepository repository; - - public JpaOtpAuditLogAdapter(OtpAuditLogJpaRepository repository) { - this.repository = repository; - } - - @Override - @Transactional - public void record(VerificationAuditEntry entry) { - repository.save(new OtpAuditLogEntity(entry)); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java deleted file mode 100644 index 3bebd17..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogEntity.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.Instant; -import java.util.UUID; - -@Entity -@Table(name = "identity_audit_log") -public class OtpAuditLogEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - - @Column(name = "challenge_id", nullable = false) - private String challengeId; - - @Column(name = "user_id", nullable = false, length = 255) - private String userId; - - @Column(nullable = false, length = 32) - private String action; - - @Column(nullable = false, length = 128) - private String reason; - - @Column(name = "source_ip", length = 64) - private String sourceIp; - - @Column(name = "occurred_at", nullable = false) - private Instant occurredAt; - - protected OtpAuditLogEntity() {} - - OtpAuditLogEntity(VerificationAuditEntry entry) { - this.challengeId = entry.challengeId().value(); - this.userId = entry.customerId().getValue(); - this.action = entry.action().name(); - this.reason = entry.reasonCode(); - this.sourceIp = entry.sourceIp(); - this.occurredAt = entry.occurredAt(); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java deleted file mode 100644 index 1af6d22..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpAuditLogJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; - -interface OtpAuditLogJpaRepository extends JpaRepository {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java deleted file mode 100644 index e617090..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java +++ /dev/null @@ -1,131 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.Instant; - -@Entity -@Table(name = "identity_challenge") -public class OtpChallengeEntity { - - @Id - @Column(length = 26) - private String id; - - @Column(name = "user_id", nullable = false, length = 255) - private String userId; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private OtpPurpose purpose; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private OtpChannel channel; - - @Enumerated(EnumType.STRING) - @Column(name = "verification_mode", nullable = false, length = 32) - private OtpVerificationMode verificationMode; - - @Column(name = "code_hash", length = 512) - private String codeHash; - - @Column(name = "device_binding_hash", length = 512) - private String deviceBindingHash; - - @Column(name = "attempts", nullable = false) - private int attempts; - - @Column(name = "max_attempts", nullable = false) - private int maxAttempts; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private OtpStatus status; - - @Column(name = "created_at", nullable = false) - private Instant createdAt; - - @Column(name = "expires_at", nullable = false) - private Instant expiresAt; - - @Column(name = "consumed_at") - private Instant consumedAt; - - @Column(name = "retention_until", nullable = false) - private Instant retentionUntil; - - protected OtpChallengeEntity() {} - - private OtpChallengeEntity(OTP challenge) { - copyFrom(challenge); - } - - static OtpChallengeEntity from(OTP challenge) { - return new OtpChallengeEntity(challenge); - } - - final void copyFrom(OTP challenge) { - id = challenge.getId().value(); - userId = challenge.getCustomerId().getValue(); - purpose = challenge.getSnapshot().purpose(); - channel = challenge.getSnapshot().channel(); - verificationMode = challenge.getSnapshot().verificationMode(); - codeHash = challenge.getSnapshot().codeHash(); - deviceBindingHash = challenge.getSnapshot().deviceBindingHash(); - attempts = challenge.getSnapshot().attempts(); - maxAttempts = challenge.getSnapshot().maxAttempts(); - status = challenge.getSnapshot().status(); - createdAt = challenge.getSnapshot().createdAt(); - expiresAt = challenge.getSnapshot().expiresAt(); - consumedAt = challenge.getSnapshot().consumedAt(); - retentionUntil = challenge.getSnapshot().retentionUntil(); - } - - OTP toDomain() { - return OTP.restore( - OtpId.parse(id), - CustomerId.parse(userId), - OtpSnapshot.create( - purpose, - channel, - verificationMode, - codeHash, - deviceBindingHash, - maxAttempts, - createdAt, - expiresAt, - retentionUntil, - attempts, - status, - consumedAt)); - } - - public String getId() { - return id; - } - - public String getUserId() { - return userId; - } - - public OtpPurpose getPurpose() { - return purpose; - } - - public OtpStatus getStatus() { - return status; - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java deleted file mode 100644 index 2c0694f..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.persistence; - -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import jakarta.persistence.LockModeType; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -interface OtpChallengeJpaRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - List findByUserIdAndPurposeAndStatus( - String userId, OtpPurpose purpose, OtpStatus status); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select c from OtpChallengeEntity c where c.id = :id") - Optional findLockedById(@Param("id") String id); -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java deleted file mode 100644 index e3bef71..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/redis/RedisRateLimiterAdapter.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.redis; - -import dev.amg.payguard.identity.verification.application.port.VerificationQuota; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -@Component -public class RedisRateLimiterAdapter implements VerificationQuota { - - private final StringRedisTemplate redis; - private final int maxIssues; - private final int maxVerifications; - private final Duration window; - - public RedisRateLimiterAdapter( - StringRedisTemplate redis, - @Value("${identity.rate-limit.max-issues:3}") int maxIssues, - @Value("${identity.rate-limit.max-verifications:20}") int maxVerifications, - @Value("${identity.rate-limit.window:PT15M}") Duration window) { - this.redis = redis; - this.maxIssues = maxIssues; - this.maxVerifications = maxVerifications; - this.window = window; - } - - @Override - public boolean tryAcquire(VerificationQuota.Request request) { - boolean issuing = request.operation() == VerificationQuota.Operation.ISSUE; - return allow( - issuing ? "issue" : "verify", - request.customerId().getValue(), - request.purpose(), - request.sourceIp(), - issuing ? maxIssues : maxVerifications); - } - - private boolean allow( - String operation, String userId, OtpPurpose purpose, String sourceIp, int maximum) { - String key = - "identity:rate:" + operation + ":" + safe(userId) + ":" + purpose + ":" + safe(sourceIp); - Long count = redis.opsForValue().increment(key); - if (count != null && count == 1L) { - redis.expire(key, window); - } - return count != null && count <= maximum; - } - - private static String safe(String value) { - return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_"); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java deleted file mode 100644 index 3176f61..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpAuthenticatorCodeVerifier.java +++ /dev/null @@ -1,83 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.security; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort; -import dev.amg.payguard.identity.verification.application.port.TotpVerification; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Locale; -import java.util.Optional; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import org.springframework.stereotype.Component; - -@Component -public final class TotpAuthenticatorCodeVerifier implements TotpVerification { - - private static final int DIGITS = 6; - private static final long STEP_SECONDS = 30; - private final AuthenticatorSecretPort secrets; - - public TotpAuthenticatorCodeVerifier(AuthenticatorSecretPort secrets) { - this.secrets = secrets; - } - - @Override - public boolean verify(CustomerId customerId, String code, Instant at) { - if (code == null || !code.matches("\\d{" + DIGITS + "}")) { - return false; - } - Optional secret = secrets.secretFor(customerId); - if (secret.isEmpty()) { - return false; - } - long counter = at.getEpochSecond() / STEP_SECONDS; - for (long offset = -1; offset <= 1; offset++) { - if (constantTimeEquals(code, generate(secret.get(), counter + offset))) { - return true; - } - } - return false; - } - - private String generate(String encodedSecret, long counter) { - try { - byte[] secret = decodeBase32(encodedSecret); - Mac mac = Mac.getInstance("HmacSHA1"); - mac.init(new SecretKeySpec(secret, "HmacSHA1")); - byte[] digest = mac.doFinal(ByteBuffer.allocate(Long.BYTES).putLong(counter).array()); - int offset = digest[digest.length - 1] & 0x0f; - int binary = - ((digest[offset] & 0x7f) << 24) - | ((digest[offset + 1] & 0xff) << 16) - | ((digest[offset + 2] & 0xff) << 8) - | (digest[offset + 3] & 0xff); - return "%06d".formatted(binary % 1_000_000); - } catch (java.security.GeneralSecurityException | IllegalArgumentException exception) { - return ""; - } - } - - private static byte[] decodeBase32(String input) { - String normalized = input.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT); - StringBuilder bits = new StringBuilder(); - for (char character : normalized.toCharArray()) { - int value = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character); - if (value < 0) { - throw new IllegalArgumentException("Invalid base32 secret"); - } - bits.append(String.format("%5s", Integer.toBinaryString(value)).replace(' ', '0')); - } - byte[] decoded = new byte[bits.length() / 8]; - for (int index = 0; index < decoded.length; index++) { - decoded[index] = (byte) Integer.parseInt(bits.substring(index * 8, index * 8 + 8), 2); - } - return decoded; - } - - private static boolean constantTimeEquals(String left, String right) { - return java.security.MessageDigest.isEqual( - left.getBytes(StandardCharsets.UTF_8), right.getBytes(StandardCharsets.UTF_8)); - } -} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java deleted file mode 100644 index 48eebbd..0000000 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpController.java +++ /dev/null @@ -1,97 +0,0 @@ -package dev.amg.payguard.identity.verification.interfaces.rest; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.IssueOtpCommand; -import dev.amg.payguard.identity.verification.application.IssuedOtp; -import dev.amg.payguard.identity.verification.application.OtpApplicationService; -import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; -import dev.amg.payguard.identity.verification.application.VerifiedOtp; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import java.time.Instant; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/identity/challenges") -public class OtpController { - - private final OtpApplicationService service; - - public OtpController(OtpApplicationService service) { - this.service = service; - } - - @PostMapping - public ResponseEntity issue( - @Valid @RequestBody IssueOtpRequest request, HttpServletRequest httpRequest) { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - CustomerId.parse(request.userId()), - request.purpose(), - request.channel(), - request.destinationRef(), - request.deviceFingerprint(), - sourceIp(httpRequest))); - return ResponseEntity.ok( - new IssueOtpResponse( - issued.challengeId().value(), issued.purpose(), issued.channel(), issued.expiresAt())); - } - - @PostMapping("/{challengeId}/verify") - public ResponseEntity verify( - @PathVariable String challengeId, - @Valid @RequestBody VerifyOtpRequest request, - HttpServletRequest httpRequest) { - VerifiedOtp verified = - service.verify( - new OtpVerificationCommand( - OtpId.parse(challengeId), - CustomerId.parse(request.userId()), - request.purpose(), - request.code(), - request.deviceFingerprint(), - sourceIp(httpRequest))); - return ResponseEntity.ok( - new VerifyOtpResponse( - verified.challengeId().value(), verified.purpose(), verified.verifiedAt())); - } - - private static String sourceIp(HttpServletRequest request) { - return request.getRemoteAddr(); - } - - public record IssueOtpRequest( - @NotBlank String userId, - @NotNull OtpPurpose purpose, - @NotNull OtpChannel channel, - String destinationRef, - String deviceFingerprint) {} - - public record VerifyOtpRequest( - @NotBlank String userId, - @NotNull OtpPurpose purpose, - @NotBlank @Pattern(regexp = "\\d{6}") String code, - String deviceFingerprint) { - @Override - public String toString() { - return "VerifyOtpRequest[code=[REDACTED]]"; - } - } - - public record IssueOtpResponse( - String challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} - - public record VerifyOtpResponse(String challengeId, OtpPurpose purpose, Instant verifiedAt) {} -} diff --git a/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql b/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql deleted file mode 100644 index 8b81e99..0000000 --- a/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql +++ /dev/null @@ -1,41 +0,0 @@ -CREATE TABLE identity_challenge ( - id RAW(16) NOT NULL, - user_id VARCHAR2(255 CHAR) NOT NULL, - purpose VARCHAR2(32 CHAR) NOT NULL, - channel VARCHAR2(32 CHAR) NOT NULL, - verification_mode VARCHAR2(32 CHAR) NOT NULL, - code_hash VARCHAR2(512 CHAR), - device_binding_hash VARCHAR2(512 CHAR), - attempts NUMBER(3) DEFAULT 0 NOT NULL, - max_attempts NUMBER(3) NOT NULL, - status VARCHAR2(20 CHAR) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - consumed_at TIMESTAMP WITH TIME ZONE, - retention_until TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT pk_identity_challenge PRIMARY KEY (id), - CONSTRAINT ck_identity_challenge_attempts CHECK (attempts >= 0 AND attempts <= max_attempts) -); - -CREATE INDEX ix_identity_challenge_active - ON identity_challenge (user_id, purpose, status); -CREATE INDEX ix_identity_challenge_retention - ON identity_challenge (retention_until); - -CREATE TABLE identity_audit_log ( - id RAW(16) DEFAULT SYS_GUID() NOT NULL, - challenge_id RAW(16) NOT NULL, - user_id VARCHAR2(255 CHAR) NOT NULL, - action VARCHAR2(32 CHAR) NOT NULL, - reason VARCHAR2(128 CHAR) NOT NULL, - source_ip VARCHAR2(64 CHAR), - occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT pk_identity_audit_log PRIMARY KEY (id), - CONSTRAINT fk_identity_audit_challenge FOREIGN KEY (challenge_id) - REFERENCES identity_challenge (id) -); - -CREATE INDEX ix_identity_audit_subject_time - ON identity_audit_log (user_id, occurred_at); -CREATE INDEX ix_identity_audit_challenge - ON identity_audit_log (challenge_id, occurred_at); diff --git a/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql b/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql deleted file mode 100644 index 003463e..0000000 --- a/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql +++ /dev/null @@ -1,25 +0,0 @@ --- UUID-to-prefixed-ID mapping cannot be inferred. Fail explicitly for populated --- installations instead of deleting rows or inventing customer/challenge IDs. -DECLARE - challenge_count NUMBER; - audit_count NUMBER; -BEGIN - SELECT COUNT(*) INTO challenge_count FROM identity_challenge; - SELECT COUNT(*) INTO audit_count FROM identity_audit_log; - IF challenge_count > 0 OR audit_count > 0 THEN - RAISE_APPLICATION_ERROR(-20001, - 'Prefixed OTP IDs require an approved mapping for existing challenge/audit rows'); - END IF; -END; -/ - -ALTER TABLE identity_audit_log DROP CONSTRAINT fk_identity_audit_challenge; --- Oracle represents the time-zone component of this composite index as a --- function-based expression, preventing modification of another indexed column. -DROP INDEX ix_identity_audit_challenge; -ALTER TABLE identity_challenge MODIFY (id VARCHAR2(26 CHAR)); -ALTER TABLE identity_audit_log MODIFY (challenge_id VARCHAR2(26 CHAR)); -ALTER TABLE identity_audit_log ADD CONSTRAINT fk_identity_audit_challenge - FOREIGN KEY (challenge_id) REFERENCES identity_challenge (id); -CREATE INDEX ix_identity_audit_challenge - ON identity_audit_log (challenge_id, occurred_at); diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java deleted file mode 100644 index f9eab22..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java +++ /dev/null @@ -1,249 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.port.OtpDelivery; -import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; -import dev.amg.payguard.identity.verification.application.port.TotpVerification; -import dev.amg.payguard.identity.verification.application.port.VerificationAudit; -import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; -import dev.amg.payguard.identity.verification.application.port.VerificationQuota; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.port.OtpRepository; -import dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -class OtpApplicationContractsTest { - private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); - private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); - private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); - private final OtpRepository repository = mock(OtpRepository.class); - private final OtpDelivery delivery = mock(OtpDelivery.class); - private final VerificationAudit audit = mock(VerificationAudit.class); - private final VerificationQuota quota = mock(VerificationQuota.class); - private final TotpVerification totp = mock(TotpVerification.class); - private final OtpEventPublisher events = mock(OtpEventPublisher.class); - private OtpApplicationService service; - - @BeforeEach - void setUp() { - when(quota.tryAcquire(any())).thenReturn(true); - service = - new OtpApplicationService( - repository, - new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars"), - () -> "123456", - totp, - delivery, - quota, - () -> ID, - events, - audit, - Clock.fixed(NOW, ZoneOffset.UTC), - Duration.ofMinutes(5), - Duration.ofSeconds(30), - Duration.ofDays(1097), - 5); - } - - @Test - void savesBeforeDeliveryAndAuditsWithoutSecrets() { - service.issue(issue(OtpChannel.SMS)); - var order = inOrder(repository, delivery, events, audit); - order.verify(repository).replaceActive(any(OTP.class)); - var payload = ArgumentCaptor.forClass(OtpDeliveryRequest.class); - order.verify(delivery).submit(payload.capture()); - order.verify(events).publish(any()); - var entry = ArgumentCaptor.forClass(VerificationAuditEntry.class); - order.verify(audit).record(entry.capture()); - assertEquals("123456", payload.getValue().code()); - assertEquals("identity:phone:1", payload.getValue().destinationRef()); - assertFalse(payload.getValue().toString().contains("123456")); - assertEquals(CUSTOMER, entry.getValue().customerId()); - assertEquals(ID, entry.getValue().challengeId()); - assertEquals(VerificationAuditEntry.Action.ISSUED, entry.getValue().action()); - assertFalse(entry.getValue().toString().contains("123456")); - } - - @Test - void deliveryFailurePropagatesAfterPersistenceWithoutAuditOrEvent() { - var failure = new IllegalStateException("provider unavailable"); - doThrow(failure).when(delivery).submit(any()); - assertSame( - failure, - assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS)))); - verify(repository).replaceActive(any()); - verifyNoInteractions(audit, events); - } - - @Test - void issuanceAuditFailurePropagatesAfterDelivery() { - var failure = new IllegalStateException("audit unavailable"); - doThrow(failure).when(audit).record(any()); - assertSame( - failure, - assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS)))); - verify(delivery).submit(any()); - verify(repository).replaceActive(any()); - } - - @Test - void persistenceFailureStopsDeliveryAndAudit() { - doThrow(new IllegalStateException("database unavailable")) - .when(repository) - .replaceActive(any()); - assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS))); - verifyNoInteractions(delivery, audit, events); - } - - @Test - void quotaDenialStopsIssuance() { - when(quota.tryAcquire(any())).thenReturn(false); - assertThrows(OtpRateLimitExceededException.class, () -> service.issue(issue(OtpChannel.SMS))); - verifyNoInteractions(repository, delivery, audit, events); - } - - @Test - void wrongCustomerCannotReachQuotaOrVerification() { - OTP otp = issuedChallenge(OtpChannel.SMS); - clearInvocations(quota, repository, audit, delivery, events); - assertThrows( - OtpChallengeNotFoundException.class, - () -> - service.verify( - new OtpVerificationCommand( - ID, - CustomerId.parse("cus_ZBCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - "123456", - null, - "127.0.0.1"))); - verifyNoInteractions(quota, totp, audit); - assertEquals(0, otp.getSnapshot().attempts()); - } - - @Test - void verificationAuditFailureDoesNotReturnSuccessOrPublishVerifiedEvent() { - OTP otp = issuedChallenge(OtpChannel.SMS); - clearInvocations(events); - doThrow(new IllegalStateException("audit unavailable")).when(audit).record(any()); - assertThrows(IllegalStateException.class, () -> service.verify(verification("123456"))); - verify(repository).withLockedChallenge(eq(ID), any()); - assertEquals(OtpStatus.USED, otp.getSnapshot().status()); - verifyNoInteractions(events); - } - - @Test - void failedVerificationPersistenceStopsAuditAndSuccessResponse() { - issuedChallenge(OtpChannel.SMS); - clearInvocations(audit, events); - doThrow(new IllegalStateException("database unavailable")) - .when(repository) - .withLockedChallenge(eq(ID), any()); - assertThrows(IllegalStateException.class, () -> service.verify(verification("123456"))); - verifyNoInteractions(audit, events); - } - - @Test - void totpUsesTrustedVerifierAndNeverTreatsAnArbitraryCodeAsSuccess() { - OTP otp = issuedChallenge(OtpChannel.AUTHENTICATOR_APP); - verifyNoInteractions(delivery); - assertEquals( - OtpVerificationResult.INVALID_CODE, - assertThrows(OtpVerificationException.class, () -> service.verify(verification("123456"))) - .result()); - verify(totp).verify(CUSTOMER, "123456", NOW); - assertEquals(1, otp.getSnapshot().attempts()); - when(totp.verify(CUSTOMER, "123456", NOW)).thenReturn(true); - assertEquals(ID, service.verify(verification("123456")).challengeId()); - } - - @Test - void failedAttemptIsSavedAndAuditedBeforeRejection() { - OTP otp = issuedChallenge(OtpChannel.SMS); - clearInvocations(repository, audit, events); - assertEquals( - OtpVerificationResult.INVALID_CODE, - assertThrows(OtpVerificationException.class, () -> service.verify(verification("000000"))) - .result()); - var order = inOrder(repository, audit, events); - order.verify(repository).findById(ID); - order.verify(repository).withLockedChallenge(eq(ID), any()); - order - .verify(audit) - .record( - new VerificationAuditEntry( - ID, - CUSTOMER, - VerificationAuditEntry.Action.VERIFY, - "invalid_code", - "127.0.0.1", - NOW)); - order.verify(events).publish(any()); - } - - @Test - void authenticatorDependencyFailureDoesNotConsumeAttemptOrPersistInvalidCode() { - OTP otp = issuedChallenge(OtpChannel.AUTHENTICATOR_APP); - clearInvocations(repository, audit, events); - when(totp.verify(CUSTOMER, "123456", NOW)) - .thenThrow(new infrastructure.vault.VaultUnavailableException()); - assertThrows( - infrastructure.vault.VaultUnavailableException.class, - () -> service.verify(verification("123456"))); - assertEquals(0, otp.getSnapshot().attempts()); - assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); - org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()) - .withLockedChallenge(eq(ID), any()); - verifyNoInteractions(audit, events); - } - - private OTP issuedChallenge(OtpChannel channel) { - service.issue(issue(channel)); - var capture = ArgumentCaptor.forClass(OTP.class); - verify(repository).replaceActive(capture.capture()); - when(repository.findById(ID)).thenReturn(Optional.of(capture.getValue())); - when(repository.withLockedChallenge(eq(ID), any())) - .thenAnswer( - invocation -> { - java.util.function.Function mutation = invocation.getArgument(1); - return Optional.of(mutation.apply(capture.getValue())); - }); - return capture.getValue(); - } - - private static IssueOtpCommand issue(OtpChannel channel) { - return new IssueOtpCommand( - CUSTOMER, OtpPurpose.LOGIN, channel, "identity:phone:1", null, "127.0.0.1"); - } - - private static OtpVerificationCommand verification(String code) { - return new OtpVerificationCommand(ID, CUSTOMER, OtpPurpose.LOGIN, code, null, "127.0.0.1"); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java deleted file mode 100644 index eec7089..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationServiceTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package dev.amg.payguard.identity.verification.application; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.port.OtpDelivery; -import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; -import dev.amg.payguard.identity.verification.application.port.VerificationQuota; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; -import dev.amg.payguard.identity.verification.domain.port.OtpRepository; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class OtpApplicationServiceTest { - - private final FixedHasher hasher = new FixedHasher(); - private final InMemoryChallenges challenges = new InMemoryChallenges(); - private final CapturingDelivery delivery = new CapturingDelivery(); - private OtpApplicationService service; - - @BeforeEach - void setUp() { - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - service = - new OtpApplicationService( - challenges, - hasher, - () -> "123456", - (userId, code, at) -> false, - delivery, - new AllowAllVerificationQuota(), - () -> OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), - event -> {}, - entry -> {}, - Clock.fixed(now, ZoneOffset.UTC), - Duration.ofMinutes(5), - Duration.ofSeconds(30), - Duration.ofDays(1097), - 5); - } - - @Test - void issueAndVerifyReturnsCompletedChallengeAndRejectsReplay() { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - OtpChannel.SMS, - "identity:phone:1", - null, - "127.0.0.1")); - - assertEquals("123456", delivery.code); - VerifiedOtp verified = - service.verify( - new OtpVerificationCommand( - issued.challengeId(), - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - "123456", - null, - "127.0.0.1")); - - assertEquals(issued.challengeId(), verified.challengeId()); - assertEquals(Instant.parse("2026-01-01T00:00:00Z"), verified.verifiedAt()); - OtpVerificationException replay = - assertThrows( - OtpVerificationException.class, - () -> - service.verify( - new OtpVerificationCommand( - issued.challengeId(), - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - "123456", - null, - "127.0.0.1"))); - assertEquals(OtpVerificationResult.ALREADY_USED, replay.result()); - } - - @Test - void wrongPurposeCannotVerifyChallenge() { - IssuedOtp issued = - service.issue( - new IssueOtpCommand( - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - OtpChannel.EMAIL, - "identity:email:1", - null, - "127.0.0.1")); - - OtpVerificationException exception = - assertThrows( - OtpVerificationException.class, - () -> - service.verify( - new OtpVerificationCommand( - issued.challengeId(), - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOAN_DISBURSE, - "123456", - null, - "127.0.0.1"))); - assertEquals(OtpVerificationResult.PURPOSE_MISMATCH, exception.result()); - } - - private static final class FixedHasher implements OtpCodeHasher { - @Override - public String hash(String value) { - return "hash:" + value; - } - - @Override - public boolean matches(String value, String encodedHash) { - return encodedHash.equals(hash(value)); - } - } - - private static final class CapturingDelivery implements OtpDelivery { - private String code; - - @Override - public void submit(OtpDeliveryRequest message) { - code = message.code(); - } - } - - private static final class AllowAllVerificationQuota implements VerificationQuota { - @Override - public boolean tryAcquire(VerificationQuota.Request request) { - return true; - } - } - - private static final class InMemoryChallenges implements OtpRepository { - @Override - public Optional withLockedChallenge( - OtpId id, java.util.function.Function mutation) { - return findById(id).map(mutation); - } - - private final Map values = new HashMap<>(); - - @Override - public void replaceActive(OTP replacement) { - values.values().stream() - .filter( - challenge -> - challenge.getCustomerId().equals(replacement.getCustomerId()) - && challenge.getSnapshot().purpose() == replacement.getSnapshot().purpose()) - .forEach(OTP::invalidate); - save(replacement); - } - - @Override - public void save(OTP challenge) { - values.put(challenge.getId(), challenge); - } - - @Override - public Optional findById(OtpId challengeId) { - return Optional.ofNullable(values.get(challengeId)); - } - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java deleted file mode 100644 index 33b3c24..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPDomainContractTest.java +++ /dev/null @@ -1,248 +0,0 @@ -package dev.amg.payguard.identity.verification.domain; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.HashSet; -import java.util.Set; -import org.junit.jupiter.api.Test; - -class OTPDomainContractTest { - private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); - private static final Instant EXPIRES = NOW.plusSeconds(300); - private static final Instant RETAINS = EXPIRES.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); - private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); - private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); - private static final OtpCodeHasher HASHER = - new OtpCodeHasher() { - @Override - public String hash(String value) { - return "hash:" + value; - } - - @Override - public boolean matches(String value, String encodedHash) { - return hash(value).equals(encodedHash); - } - }; - - @Test - void issueRequiresActiveZeroAttemptSnapshot() { - assertEquals( - OtpStatus.ACTIVE, - OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)).getSnapshot().status()); - assertThrows( - IllegalArgumentException.class, - () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 1, 3, null))); - assertThrows( - IllegalArgumentException.class, - () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW))); - assertThrows( - IllegalArgumentException.class, - () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null))); - assertThrows( - IllegalArgumentException.class, - () -> OTP.issue(ID, CUSTOMER, state(OtpStatus.EXPIRED, 1, 3, null))); - } - - @Test - void issueRejectsNullArgumentsAtRuntime() { - OtpSnapshot initial = state(OtpStatus.ACTIVE, 0, 3, null); - assertThrows(NullPointerException.class, () -> OTP.issue(null, CUSTOMER, initial)); - assertThrows(NullPointerException.class, () -> OTP.issue(ID, null, initial)); - assertThrows(NullPointerException.class, () -> OTP.issue(ID, CUSTOMER, null)); - } - - @Test - void restoreAcceptsHistoricalStatesWithoutReset() { - for (OtpSnapshot state : - new OtpSnapshot[] { - state(OtpStatus.ACTIVE, 2, 3, null), - state(OtpStatus.USED, 2, 3, NOW.plusSeconds(1)), - state(OtpStatus.LOCKED, 3, 3, null), - state(OtpStatus.EXPIRED, 1, 3, null), - state(OtpStatus.INVALIDATED, 1, 3, null) - }) { - OTP restored = OTP.restore(ID, CUSTOMER, state); - assertEquals(ID, restored.getId()); - assertEquals(CUSTOMER, restored.getCustomerId()); - assertSame(state, restored.getSnapshot()); - assertEquals(state.attempts(), restored.getSnapshot().attempts()); - assertEquals(state.consumedAt(), restored.getSnapshot().consumedAt()); - } - } - - @Test - void restoreRejectsNullArgumentsAtRuntime() { - OtpSnapshot existing = state(OtpStatus.USED, 1, 3, NOW); - assertThrows(NullPointerException.class, () -> OTP.restore(null, CUSTOMER, existing)); - assertThrows(NullPointerException.class, () -> OTP.restore(ID, null, existing)); - assertThrows(NullPointerException.class, () -> OTP.restore(ID, CUSTOMER, null)); - } - - @Test - void correctCodeSucceedsOnLastRemainingAttemptAfterPreviousFailures() { - OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.ACTIVE, 2, 3, null)); - assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER)); - assertEquals(OtpStatus.USED, otp.getSnapshot().status()); - assertEquals(3, otp.getSnapshot().attempts()); - assertEquals(NOW, otp.getSnapshot().consumedAt()); - } - - @Test - void failedAttemptDoesNotRequireDeviceBindingOrConsumptionTime() { - OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); - OtpSnapshot before = otp.getSnapshot(); - assertEquals(OtpVerificationResult.INVALID_CODE, otp.verify(command("000000", NOW), HASHER)); - assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); - assertEquals(1, otp.getSnapshot().attempts()); - assertNull(otp.getSnapshot().consumedAt()); - assertNull(otp.getSnapshot().deviceBindingHash()); - assertEquals(0, before.attempts()); - } - - @Test - void consumedChallengeCannotBeReusedOrMutated() { - OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW)); - OtpSnapshot before = otp.getSnapshot(); - assertEquals( - OtpVerificationResult.ALREADY_USED, - otp.verify(command("123456", NOW.plusSeconds(2)), HASHER)); - assertSame(before, otp.getSnapshot()); - } - - @Test - void lockedChallengeCannotBeReusedOrMutated() { - OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.LOCKED, 3, 3, null)); - OtpSnapshot before = otp.getSnapshot(); - assertEquals(OtpVerificationResult.LOCKED, otp.verify(command("123456", NOW), HASHER)); - assertSame(before, otp.getSnapshot()); - } - - @Test - void verificationAtExpirationTransitionsToExpiredWithoutCountingAttempt() { - OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); - assertEquals(OtpVerificationResult.EXPIRED, otp.verify(command("123456", EXPIRES), HASHER)); - assertEquals(OtpStatus.EXPIRED, otp.getSnapshot().status()); - assertEquals(0, otp.getSnapshot().attempts()); - assertNull(otp.getSnapshot().consumedAt()); - } - - @Test - void verificationBeforeCreationIsInvalidEvenForWrongCode() { - OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); - OtpSnapshot before = otp.getSnapshot(); - assertThrows( - IllegalArgumentException.class, - () -> otp.verify(command("000000", NOW.minusSeconds(1)), HASHER)); - assertSame(before, otp.getSnapshot()); - } - - @Test - void hasherFailureInDeviceBindingGuardPreservesState() { - OtpSnapshot withBinding = - new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - "hash:123456", - "hash:device-a", - 3, - NOW, - EXPIRES, - RETAINS, - 0, - OtpStatus.ACTIVE, - null); - OTP otp = OTP.issue(ID, CUSTOMER, withBinding); - OtpCodeHasher failing = - new OtpCodeHasher() { - @Override - public String hash(String value) { - throw new IllegalStateException("hash unavailable"); - } - - @Override - public boolean matches(String value, String encodedHash) { - throw new IllegalStateException("hash unavailable"); - } - }; - assertThrows( - IllegalStateException.class, - () -> - otp.verify( - new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", "device-a", NOW, false), failing)); - assertSame(withBinding, otp.getSnapshot()); - } - - @Test - void invalidatingActiveChallengeIsCompleteAndIdempotentInMemory() { - OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); - otp.invalidate(); - assertEquals(OtpStatus.INVALIDATED, otp.getSnapshot().status()); - assertEquals(0, otp.getSnapshot().attempts()); - OtpSnapshot invalidated = otp.getSnapshot(); - otp.invalidate(); - assertSame(invalidated, otp.getSnapshot()); - } - - @Test - void invalidatingUsedChallengeDoesNotChangeIt() { - OTP otp = OTP.restore(ID, CUSTOMER, state(OtpStatus.USED, 1, 3, NOW)); - OtpSnapshot before = otp.getSnapshot(); - otp.invalidate(); - assertSame(before, otp.getSnapshot()); - } - - @Test - void identityHashingDoesNotChangeWhenSnapshotChanges() { - OTP otp = OTP.issue(ID, CUSTOMER, state(OtpStatus.ACTIVE, 0, 3, null)); - Set known = new HashSet<>(); - known.add(otp); - int beforeHash = otp.hashCode(); - assertEquals(OtpVerificationResult.VERIFIED, otp.verify(command("123456", NOW), HASHER)); - assertEquals(beforeHash, otp.hashCode()); - assertTrue(known.contains(otp)); - OTP restored = OTP.restore(ID, CUSTOMER, otp.getSnapshot()); - assertEquals(otp, restored); - assertFalse(otp == restored); - } - - private static VerifyOtpCommand command(String code, Instant now) { - return new VerifyOtpCommand(OtpPurpose.LOGIN, code, null, now, false); - } - - private static OtpSnapshot state( - OtpStatus status, int attempts, int maxAttempts, Instant consumedAt) { - return new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - "hash:123456", - null, - maxAttempts, - NOW, - EXPIRES, - RETAINS, - attempts, - status, - consumedAt); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java deleted file mode 100644 index f286ed1..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OTPTest.java +++ /dev/null @@ -1,338 +0,0 @@ -package dev.amg.payguard.identity.verification.domain; - -import static org.junit.jupiter.api.Assertions.*; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.*; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import java.time.Instant; -import java.time.ZoneOffset; -import java.util.HashSet; -import java.util.Set; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; - -class OTPTest { - - private static final Instant CREATED_AT = Instant.parse("2026-01-01T00:00:00Z"); - private static final Instant EXPIRES_AT = CREATED_AT.plusSeconds(300); - private static final Instant RETENTION_UNTIL = - EXPIRES_AT.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); - - private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); - private static final CustomerId CUSTOMER_ID = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); - - // Test double only. Never use this hasher in production. - private static final OtpCodeHasher HASHER = - new OtpCodeHasher() { - @Override - public String hash(String value) { - return "hash:" + value; - } - - @Override - public boolean matches(String value, String encodedHash) { - return encodedHash.equals(hash(value)); - } - }; - - private static OtpSnapshot snapshot( - OtpStatus status, - int attempts, - int maxAttempts, - @Nullable Instant consumedAt, - @Nullable String deviceHash) { - - return new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - "hash:123456", - deviceHash, - maxAttempts, - CREATED_AT, - EXPIRES_AT, - RETENTION_UNTIL, - attempts, - status, - consumedAt); - } - - private static OTP issue(int maxAttempts) { - return OTP.issue(ID, CUSTOMER_ID, snapshot(OtpStatus.ACTIVE, 0, maxAttempts, null, null)); - } - - private static VerifyOtpCommand command( - OtpPurpose purpose, String code, Instant now, @Nullable String device) { - - return new VerifyOtpCommand(purpose, code, device, now, false); - } - - @Test - void issueRequiresFreshActiveSnapshot() { - OtpSnapshot attempted = snapshot(OtpStatus.ACTIVE, 1, 3, null, null); - - OtpSnapshot consumed = snapshot(OtpStatus.USED, 1, 3, CREATED_AT, null); - - assertThrows(IllegalArgumentException.class, () -> OTP.issue(ID, CUSTOMER_ID, attempted)); - - assertThrows(IllegalArgumentException.class, () -> OTP.issue(ID, CUSTOMER_ID, consumed)); - } - - @Test - void restorePreservesValidHistoricalStates() { - OtpSnapshot[] historicalStates = { - snapshot(OtpStatus.ACTIVE, 2, 3, null, null), - snapshot(OtpStatus.USED, 2, 3, CREATED_AT.plusSeconds(20), null), - snapshot(OtpStatus.LOCKED, 3, 3, null, null), - snapshot(OtpStatus.EXPIRED, 1, 3, null, null), - snapshot(OtpStatus.INVALIDATED, 0, 3, null, null) - }; - - for (OtpSnapshot state : historicalStates) { - OTP restored = OTP.restore(ID, CUSTOMER_ID, state); - - assertEquals(ID, restored.getId()); - assertEquals(CUSTOMER_ID, restored.getCustomerId()); - assertEquals(state, restored.getSnapshot(), state.status().name()); - } - } - - @Test - void restoreRejectsNullArguments() { - OtpSnapshot valid = snapshot(OtpStatus.ACTIVE, 0, 3, null, null); - - // Deliberate violations of the non-null contract. - assertThrows(NullPointerException.class, () -> OTP.restore(null, CUSTOMER_ID, valid)); - - assertThrows(NullPointerException.class, () -> OTP.restore(ID, null, valid)); - - assertThrows(NullPointerException.class, () -> OTP.restore(ID, CUSTOMER_ID, null)); - } - - @Test - void restoredUsedOtpCannotBeVerifiedAgain() { - OtpSnapshot used = snapshot(OtpStatus.USED, 2, 3, CREATED_AT.plusSeconds(20), null); - - OTP otp = OTP.restore(ID, CUSTOMER_ID, used); - OtpSnapshot before = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.ALREADY_USED, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT.plusSeconds(30), null), HASHER)); - - assertSame(before, otp.getSnapshot()); - } - - @Test - void invalidCodeCountsOneAttemptWithoutMutatingPreviousSnapshot() { - OTP otp = issue(3); - OtpSnapshot before = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.INVALID_CODE, - otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); - - assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); - assertEquals(1, otp.getSnapshot().attempts()); - assertNull(otp.getSnapshot().consumedAt()); - - assertEquals(0, before.attempts()); - assertEquals(OtpStatus.ACTIVE, before.status()); - assertNotSame(before, otp.getSnapshot()); - } - - @Test - void finalInvalidAttemptLocksOtpAndPreventsFurtherVerification() { - OTP otp = issue(2); - - assertEquals( - OtpVerificationResult.INVALID_CODE, - otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); - - assertEquals( - OtpVerificationResult.LOCKED, - otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); - - assertEquals(OtpStatus.LOCKED, otp.getSnapshot().status()); - assertEquals(2, otp.getSnapshot().attempts()); - assertNull(otp.getSnapshot().consumedAt()); - - OtpSnapshot locked = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.LOCKED, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); - - assertSame(locked, otp.getSnapshot()); - } - - @Test - void successOnLastAvailableAttemptIsSingleUse() { - OTP otp = issue(2); - - assertEquals( - OtpVerificationResult.INVALID_CODE, - otp.verify(command(OtpPurpose.LOGIN, "000000", CREATED_AT, null), HASHER)); - - Instant verifiedAt = CREATED_AT.plusSeconds(10); - - assertEquals( - OtpVerificationResult.VERIFIED, - otp.verify(command(OtpPurpose.LOGIN, "123456", verifiedAt, null), HASHER)); - - assertEquals(OtpStatus.USED, otp.getSnapshot().status()); - assertEquals(2, otp.getSnapshot().attempts()); - assertEquals(verifiedAt, otp.getSnapshot().consumedAt()); - - OtpSnapshot consumed = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.ALREADY_USED, - otp.verify(command(OtpPurpose.LOGIN, "123456", verifiedAt, null), HASHER)); - - assertSame(consumed, otp.getSnapshot()); - } - - @Test - void verificationAtExpirationMarksOtpExpiredWithoutCountingAttempt() { - OTP otp = issue(3); - - assertEquals( - OtpVerificationResult.EXPIRED, - otp.verify(command(OtpPurpose.LOGIN, "123456", EXPIRES_AT, null), HASHER)); - - assertEquals(OtpStatus.EXPIRED, otp.getSnapshot().status()); - assertEquals(0, otp.getSnapshot().attempts()); - assertNull(otp.getSnapshot().consumedAt()); - - OtpSnapshot expired = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.EXPIRED, - otp.verify(command(OtpPurpose.LOGIN, "000000", EXPIRES_AT, null), HASHER)); - - assertSame(expired, otp.getSnapshot()); - } - - @Test - void purposeMismatchRejectsBeforeExpirationWithoutChangingState() { - OTP otp = issue(3); - OtpSnapshot before = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.PURPOSE_MISMATCH, - otp.verify(command(OtpPurpose.LOAN_DISBURSE, "123456", EXPIRES_AT, null), HASHER)); - - assertSame(before, otp.getSnapshot()); - assertEquals(0, otp.getSnapshot().attempts()); - } - - @Test - void deviceMismatchDoesNotConsumeAttempt() { - OTP otp = OTP.issue(ID, CUSTOMER_ID, snapshot(OtpStatus.ACTIVE, 0, 3, null, "hash:device-a")); - - OtpSnapshot before = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.DEVICE_MISMATCH, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, "device-b"), HASHER)); - - assertSame(before, otp.getSnapshot()); - - assertEquals( - OtpVerificationResult.VERIFIED, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, "device-a"), HASHER)); - - assertEquals(OtpStatus.USED, otp.getSnapshot().status()); - assertEquals(1, otp.getSnapshot().attempts()); - } - - @Test - void invalidationPreventsVerificationAndDoesNotChangeTerminalStates() { - OTP otp = issue(3); - - otp.invalidate(); - - assertEquals(OtpStatus.INVALIDATED, otp.getSnapshot().status()); - assertEquals(0, otp.getSnapshot().attempts()); - - OtpSnapshot invalidated = otp.getSnapshot(); - - assertEquals( - OtpVerificationResult.INVALIDATED, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); - - otp.invalidate(); - assertSame(invalidated, otp.getSnapshot()); - - OTP used = issue(1); - used.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER); - - OtpSnapshot usedState = used.getSnapshot(); - used.invalidate(); - - assertSame(usedState, used.getSnapshot()); - assertEquals(OtpStatus.USED, used.getSnapshot().status()); - } - - @Test - void hasherExceptionLeavesExactSnapshotUnchanged() { - OTP otp = issue(3); - OtpSnapshot before = otp.getSnapshot(); - - OtpCodeHasher failingHasher = - new OtpCodeHasher() { - @Override - public String hash(String value) { - throw new IllegalStateException("Hashing failed"); - } - - @Override - public boolean matches(String value, String encodedHash) { - throw new IllegalStateException("Hashing failed"); - } - }; - - assertThrows( - IllegalStateException.class, - () -> otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), failingHasher)); - - assertSame(before, otp.getSnapshot()); - } - - @Test - void verificationBeforeCreationIsRejectedWithoutMutation() { - OTP otp = issue(3); - OtpSnapshot before = otp.getSnapshot(); - - // Proposed contract: invalid pre-creation time throws IllegalArgumentException. - assertThrows( - IllegalArgumentException.class, - () -> - otp.verify( - command(OtpPurpose.LOGIN, "123456", CREATED_AT.minusSeconds(1), null), HASHER)); - - assertSame(before, otp.getSnapshot()); - } - - @Test - void aggregateHashCodeRemainsStableAfterStateTransition() { - OTP otp = issue(3); - - int hashBefore = otp.hashCode(); - Set set = new HashSet<>(); - set.add(otp); - - assertEquals( - OtpVerificationResult.VERIFIED, - otp.verify(command(OtpPurpose.LOGIN, "123456", CREATED_AT, null), HASHER)); - - assertEquals(hashBefore, otp.hashCode()); - assertTrue(set.contains(otp)); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java deleted file mode 100644 index 606a255..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/OtpSnapshotTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package dev.amg.payguard.identity.verification.domain; - -import static org.junit.jupiter.api.Assertions.*; - -import dev.amg.payguard.identity.verification.domain.enums.*; -import dev.amg.payguard.identity.verification.domain.exception.*; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import java.time.Instant; -import java.time.ZoneOffset; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; - -class OtpSnapshotTest { - - private static final Instant CREATED_AT = Instant.parse("2026-01-01T00:00:00Z"); - private static final Instant EXPIRES_AT = CREATED_AT.plusSeconds(300); - private static final Instant RETENTION_UNTIL = - EXPIRES_AT.atZone(ZoneOffset.UTC).plusYears(3).toInstant(); - - private static OtpSnapshot snapshot( - OtpStatus status, - int attempts, - int maxAttempts, - @Nullable Instant consumedAt, - String codeHash, - @Nullable String deviceHash, - Instant expiresAt, - Instant retentionUntil) { - - return new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - codeHash, - deviceHash, - maxAttempts, - CREATED_AT, - expiresAt, - retentionUntil, - attempts, - status, - consumedAt); - } - - private static OtpSnapshot state( - OtpStatus status, int attempts, int maxAttempts, @Nullable Instant consumedAt) { - - return snapshot( - status, - attempts, - maxAttempts, - consumedAt, - "hash:123456", - null, - EXPIRES_AT, - RETENTION_UNTIL); - } - - @Test - void acceptsValidLifecycleStates() { - OtpSnapshot active = state(OtpStatus.ACTIVE, 0, 3, null); - OtpSnapshot used = state(OtpStatus.USED, 1, 3, CREATED_AT); - OtpSnapshot locked = state(OtpStatus.LOCKED, 3, 3, null); - OtpSnapshot expired = state(OtpStatus.EXPIRED, 1, 3, null); - OtpSnapshot invalidated = state(OtpStatus.INVALIDATED, 0, 3, null); - - assertEquals(OtpStatus.ACTIVE, active.status()); - assertNull(active.consumedAt()); - assertNull(active.deviceBindingHash()); - - assertEquals(OtpStatus.USED, used.status()); - assertEquals(CREATED_AT, used.consumedAt()); - - assertEquals(OtpStatus.LOCKED, locked.status()); - assertEquals(OtpStatus.EXPIRED, expired.status()); - assertEquals(OtpStatus.INVALIDATED, invalidated.status()); - } - - @Test - void rejectsInvalidAttemptLimits() { - assertThrows(InvalidOtpAttemptLimitException.class, () -> state(OtpStatus.ACTIVE, 0, 0, null)); - - assertThrows( - OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, -1, 3, null)); - - assertThrows( - OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, 4, 3, null)); - - // ACTIVE must always have an attempt remaining. - assertThrows( - OtpAttemptCountOutOfRangeException.class, () -> state(OtpStatus.ACTIVE, 3, 3, null)); - } - - @Test - void consumedAtMustExistExactlyWhenStatusIsUsed() { - assertThrows( - OtpConsumedTimeMustMatchUsedStatusException.class, () -> state(OtpStatus.USED, 1, 3, null)); - - assertThrows( - OtpConsumedTimeMustMatchUsedStatusException.class, - () -> state(OtpStatus.ACTIVE, 0, 3, CREATED_AT)); - - assertThrows( - OtpConsumedTimeMustMatchUsedStatusException.class, - () -> state(OtpStatus.LOCKED, 3, 3, CREATED_AT)); - } - - @Test - void consumedAtMustBeInsideValidityPeriod() { - assertThrows( - OtpConsumedTimeOutsideValidityPeriodException.class, - () -> state(OtpStatus.USED, 1, 3, CREATED_AT.minusNanos(1))); - - assertThrows( - OtpConsumedTimeOutsideValidityPeriodException.class, - () -> state(OtpStatus.USED, 1, 3, EXPIRES_AT)); - - // Consumption exactly at creation is valid. - OtpSnapshot valid = state(OtpStatus.USED, 1, 3, CREATED_AT); - assertEquals(CREATED_AT, valid.consumedAt()); - } - - @Test - void expirationMustBeStrictlyAfterCreation() { - assertThrows( - OtpExpirationTimeMustBeAfterCreationTimeException.class, - () -> - snapshot( - OtpStatus.ACTIVE, 0, 3, null, "hash:123456", null, CREATED_AT, RETENTION_UNTIL)); - - assertThrows( - OtpExpirationTimeMustBeAfterCreationTimeException.class, - () -> - snapshot( - OtpStatus.ACTIVE, - 0, - 3, - null, - "hash:123456", - null, - CREATED_AT.minusSeconds(1), - RETENTION_UNTIL)); - } - - @Test - void retentionMustRespectMinimumPeriod() { - // The current retention policy requires 3 years after expiry. - OtpSnapshot valid = state(OtpStatus.ACTIVE, 0, 3, null); - assertEquals(RETENTION_UNTIL, valid.retentionUntil()); - - assertThrows( - OtpRetentionPeriodMustNotPrecedeExpirationException.class, - () -> - snapshot( - OtpStatus.ACTIVE, - 0, - 3, - null, - "hash:123456", - null, - EXPIRES_AT, - RETENTION_UNTIL.minusNanos(1))); - } - - @Test - void numericCodeHashAndOptionalDeviceBindingAreValidated() { - assertThrows( - OtpCodeHashRequiredForRandomNumericVerificationException.class, - () -> snapshot(OtpStatus.ACTIVE, 0, 3, null, "", null, EXPIRES_AT, RETENTION_UNTIL)); - - assertThrows( - OtpCodeHashRequiredForRandomNumericVerificationException.class, - () -> snapshot(OtpStatus.ACTIVE, 0, 3, null, " ", null, EXPIRES_AT, RETENTION_UNTIL)); - - assertThrows( - OtpDeviceBindingHashMustNotBeBlankException.class, - () -> - snapshot( - OtpStatus.ACTIVE, 0, 3, null, "hash:123456", " ", EXPIRES_AT, RETENTION_UNTIL)); - - // No device binding is a valid configuration. - OtpSnapshot unbound = state(OtpStatus.ACTIVE, 0, 3, null); - assertNull(unbound.deviceBindingHash()); - } - - @Test - void diagnosticStringMustNotExposeSensitiveHashes() { - OtpSnapshot state = - snapshot( - OtpStatus.ACTIVE, - 0, - 3, - null, - "hash:123456", - "hash:device-a", - EXPIRES_AT, - RETENTION_UNTIL); - - String diagnostic = state.toString(); - - assertFalse(diagnostic.contains("hash:123456")); - assertFalse(diagnostic.contains("hash:device-a")); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java deleted file mode 100644 index d0a0cb9..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/domain/TypedIdentifiersTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package dev.amg.payguard.identity.verification.domain; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.infrastructure.security.OtpIdGenerator; -import org.junit.jupiter.api.Test; - -class TypedIdentifiersTest { - @Test - void acceptsExactFormatAndPreservesCaseAndEquality() { - String value = "cus_aBCDEFGHIJKLMNOPQRSTUV"; - CustomerId customer = CustomerId.parse(value); - assertEquals(value, customer.getValue()); - assertEquals(customer, CustomerId.parse(value)); - assertEquals(customer.hashCode(), CustomerId.parse(value).hashCode()); - assertNotEquals(customer, CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV")); - assertFalse(customer.sameValueAs(OtpId.parse("otp_aBCDEFGHIJKLMNOPQRSTUV"))); - } - - @Test - void rejectsWrongPrefixesLengthsWhitespaceAndNonAscii() { - for (String value : - new String[] { - "xxx_ABCDEFGHIJKLMNOPQRSTUV", - "cus_ABCDEFGHIJKLMNOPQRSTU", - "cus_ABCDEFGHIJKLMNOPQRSTUVW", - "cus_ABCDEFGHIJKLMNOPQRSTU ", - "cus_ABCDEFGHIJKLMNOPQRSTUé", - "" - }) { - assertThrows(IllegalArgumentException.class, () -> CustomerId.parse(value)); - } - assertThrows(NullPointerException.class, () -> CustomerId.parse(null)); - assertThrows(IllegalArgumentException.class, () -> OtpId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV")); - } - - @Test - void generatorProducesAcceptedTypedIdsWithoutUuidConversion() { - OtpIdGenerator generator = new OtpIdGenerator(); - OtpId first = generator.generate(); - assertTrue(first.value().matches("otp_[A-Za-z0-9]{22}")); - assertEquals(first, OtpId.parse(first.value())); - assertNotEquals(first, generator.generate()); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java deleted file mode 100644 index 9c0712f..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java +++ /dev/null @@ -1,544 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.when; - -import dev.amg.payguard.identity.IdentityApp; -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.IssueOtpCommand; -import dev.amg.payguard.identity.verification.application.OtpApplicationService; -import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; -import dev.amg.payguard.identity.verification.application.OtpVerificationException; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; -import dev.amg.payguard.identity.verification.domain.model.OTP; -import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; -import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; -import dev.amg.payguard.identity.verification.domain.port.OtpRepository; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.postgresql.PostgreSQLContainer; - -@Testcontainers -@org.springframework.test.context.ActiveProfiles("test") -@SpringBootTest( - classes = IdentityApp.class, - webEnvironment = SpringBootTest.WebEnvironment.NONE, - properties = {"spring.flyway.enabled=true", "spring.jpa.hibernate.ddl-auto=validate"}) -class OtpInfrastructureIntegrationTest { - @Container - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer("postgres:17.11-bookworm").withInitScript("postgres-test-init.sql"); - - @Container - static final GenericContainer REDIS = - new GenericContainer<>("redis:8.0.3-alpine").withExposedPorts(6379); - - @DynamicPropertySource - static void properties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", () -> POSTGRES.getJdbcUrl() + "¤tSchema=payguard"); - registry.add("spring.flyway.url", POSTGRES::getJdbcUrl); - registry.add("spring.flyway.user", POSTGRES::getUsername); - registry.add("spring.flyway.password", POSTGRES::getPassword); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.data.redis.host", REDIS::getHost); - registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); - } - - @Autowired OtpRepository repository; - @Autowired OtpApplicationService service; - @Autowired HmacOtpCodeHasher hasher; - @Autowired JdbcTemplate jdbc; - @Autowired StringRedisTemplate redis; - @Autowired dev.amg.payguard.identity.verification.application.port.VerificationQuota quota; - @Autowired dev.amg.payguard.identity.verification.application.port.VerificationAudit audit; - @MockitoBean OtpEventPublisher events; - @MockitoBean OtpCodeGenerator codes; - - @BeforeEach - void reset() { - jdbc.update("DELETE FROM identity_audit_log"); - jdbc.update("DELETE FROM identity_challenge"); - try (var connection = redis.getConnectionFactory().getConnection()) { - connection.serverCommands().flushDb(); - } - when(codes.generate()).thenReturn("123456"); - } - - @Test - void postgresAndRedisAreReachable() { - assertTrue(POSTGRES.isRunning()); - assertTrue(REDIS.isRunning()); - assertTrue(POSTGRES.getJdbcUrl().startsWith("jdbc:postgresql:")); - } - - @Test - void actualApplicationWiringIssuesVerifiesPersistsAndRejectsReplay() { - var customer = customer("A"); - var issued = - service.issue( - new IssueOtpCommand( - customer, OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); - var verified = - service.verify( - new OtpVerificationCommand( - issued.challengeId(), customer, OtpPurpose.LOGIN, "123456", null, "127.0.0.1")); - assertEquals(issued.challengeId(), verified.challengeId()); - OTP stored = repository.findById(issued.challengeId()).orElseThrow(); - assertEquals(customer, stored.getCustomerId()); - assertEquals(OtpStatus.USED, stored.getSnapshot().status()); - assertEquals(1, stored.getSnapshot().attempts()); - assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM identity_audit_log", Integer.class)); - assertEquals( - OtpVerificationResult.ALREADY_USED, - assertThrows( - OtpVerificationException.class, - () -> - service.verify( - new OtpVerificationCommand( - issued.challengeId(), - customer, - OtpPurpose.LOGIN, - "123456", - null, - "127.0.0.1"))) - .result()); - } - - @Test - void replacementInvalidatesOnlySameCustomerAndPurposeAndPreservesCase() { - OTP first = challenge("A", customer("A"), OtpPurpose.LOGIN, "hash"); - OTP otherPurpose = challenge("B", customer("A"), OtpPurpose.PROFILE_CHANGE, "hash"); - OTP otherCustomer = challenge("C", customer("B"), OtpPurpose.LOGIN, "hash"); - repository.save(first); - repository.save(otherPurpose); - repository.save(otherCustomer); - OTP replacement = challenge("a", customer("A"), OtpPurpose.LOGIN, "hash"); - repository.replaceActive(replacement); - assertEquals( - OtpStatus.INVALIDATED, - repository.findById(first.getId()).orElseThrow().getSnapshot().status()); - for (OTP active : new OTP[] {otherPurpose, otherCustomer, replacement}) { - assertEquals( - OtpStatus.ACTIVE, - repository.findById(active.getId()).orElseThrow().getSnapshot().status()); - } - assertEquals(4, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); - } - - @Test - void replacementFailureRollsBackEarlierInvalidation() { - OTP original = challenge("A", customer("A"), OtpPurpose.LOGIN, "hash"); - repository.save(original); - OTP oversized = challenge("B", customer("A"), OtpPurpose.LOGIN, "x".repeat(513)); - assertThrows(RuntimeException.class, () -> repository.replaceActive(oversized)); - assertEquals( - OtpStatus.ACTIVE, - repository.findById(original.getId()).orElseThrow().getSnapshot().status()); - assertTrue(repository.findById(oversized.getId()).isEmpty()); - } - - @Test - void finalSuccessAndFinalFailureRoundTripWithoutExhaustedActiveState() { - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - for (boolean valid : new boolean[] {false, true}) { - OtpId id = OtpId.parse("otp_" + (valid ? "A" : "B").repeat(22)); - OTP otp = - OTP.issue( - id, - customer("A"), - new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - hasher.hash("123456"), - null, - 1, - now, - now.plusSeconds(300), - now.plusSeconds(1097L * 86_400), - 0, - OtpStatus.ACTIVE, - null)); - repository.save(otp); - repository.withLockedChallenge( - id, - current -> - current.verify( - new VerifyOtpCommand( - OtpPurpose.LOGIN, valid ? "123456" : "000000", null, now, false), - hasher)); - OtpSnapshot stored = repository.findById(id).orElseThrow().getSnapshot(); - assertEquals(valid ? OtpStatus.USED : OtpStatus.LOCKED, stored.status()); - assertEquals(1, stored.attempts()); - } - } - - @Test - void redisActuallyEnforcesIssuanceQuotaAcrossRequests() { - var command = - new IssueOtpCommand( - customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1"); - for (int index = 0; index < 3; index++) service.issue(command); - assertThrows( - dev.amg.payguard.identity.verification.application.OtpRateLimitExceededException.class, - () -> service.issue(command)); - assertEquals(3, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); - assertEquals( - "4", - redis - .opsForValue() - .get("identity:rate:issue:" + customer("A").getValue() + ":LOGIN:127.0.0.1")); - } - - @Test - void archivedOracleMigrationRetainsPopulatedDataGuard() throws Exception { - repository.save(challenge("A", customer("A"), OtpPurpose.LOGIN, "hash")); - String migration; - try (var stream = - getClass().getResourceAsStream("/db/legacy/oracle/V2__prefixed_challenge_ids.sql")) { - migration = new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } - String guard = migration.substring(migration.indexOf("DECLARE"), migration.indexOf("\n/")); - assertTrue(guard.contains("RAISE_APPLICATION_ERROR(-20001")); - assertTrue(guard.contains("approved mapping")); - assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); - } - - @Test - void concurrentRedisQuotaAcquisitionAllowsOnlyConfiguredNumber() throws Exception { - var request = - new dev.amg.payguard.identity.verification.application.port.VerificationQuota.Request( - customer("A"), - OtpPurpose.LOGIN, - "127.0.0.1", - dev.amg.payguard.identity.verification.application.port.VerificationQuota.Operation - .ISSUE); - try (var executor = java.util.concurrent.Executors.newFixedThreadPool(8)) { - var tasks = - java.util.stream.IntStream.range(0, 32) - .mapToObj( - index -> (java.util.concurrent.Callable) () -> quota.tryAcquire(request)) - .toList(); - int accepted = 0; - for (var result : executor.invokeAll(tasks)) if (result.get()) accepted++; - assertEquals(3, accepted); - } - String key = "identity:rate:issue:" + customer("A").getValue() + ":LOGIN:127.0.0.1"; - assertEquals("32", redis.opsForValue().get(key)); - assertTrue(redis.getExpire(key) > 0); - } - - @Test - void competingSuccessfulVerificationsCommitOnlyOneFreshSuccess() throws Exception { - var issued = - service.issue( - new IssueOtpCommand( - customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); - var outcomes = verifyTogether(issued.challengeId(), "123456", 2); - assertEquals( - 1, outcomes.stream().filter(result -> result == OtpVerificationResult.VERIFIED).count()); - assertEquals( - 1, - outcomes.stream().filter(result -> result == OtpVerificationResult.ALREADY_USED).count()); - assertEquals( - 1, repository.findById(issued.challengeId()).orElseThrow().getSnapshot().attempts()); - } - - @Test - void competingFailedAttemptsNeverLoseIncrementsOrExceedLimit() throws Exception { - var issued = - service.issue( - new IssueOtpCommand( - customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); - var outcomes = verifyTogether(issued.challengeId(), "000000", 8); - assertEquals( - 4, - outcomes.stream().filter(result -> result == OtpVerificationResult.INVALID_CODE).count()); - assertEquals( - 4, outcomes.stream().filter(result -> result == OtpVerificationResult.LOCKED).count()); - OtpSnapshot stored = repository.findById(issued.challengeId()).orElseThrow().getSnapshot(); - assertEquals(5, stored.attempts()); - assertEquals(OtpStatus.LOCKED, stored.status()); - } - - @Test - void firstIssuanceAndReplacementAreSerializedWithoutAnExistingActiveRow() throws Exception { - for (boolean existing : new boolean[] {false, true}) { - var subject = customer(existing ? "B" : "A"); - if (existing) repository.save(challenge("C", subject, OtpPurpose.LOGIN, "hash")); - OTP first = challenge(existing ? "D" : "A", subject, OtpPurpose.LOGIN, "hash"); - OTP second = challenge(existing ? "E" : "B", subject, OtpPurpose.LOGIN, "hash"); - var barrier = new java.util.concurrent.CyclicBarrier(2); - try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { - var one = - executor.submit( - () -> { - await(barrier); - repository.replaceActive(first); - }); - var two = - executor.submit( - () -> { - await(barrier); - repository.replaceActive(second); - }); - one.get(15, java.util.concurrent.TimeUnit.SECONDS); - two.get(15, java.util.concurrent.TimeUnit.SECONDS); - } - assertEquals( - 1, - jdbc.queryForObject( - "SELECT count(*) FROM identity_challenge WHERE user_id=? AND purpose='LOGIN' AND status='ACTIVE'", - Integer.class, - subject.getValue())); - assertEquals( - existing ? 3 : 2, - jdbc.queryForObject( - "SELECT count(*) FROM identity_challenge WHERE user_id=?", - Integer.class, - subject.getValue())); - } - } - - @Test - void expiredAndWrongOwnerRejectionsPreserveTheirPersistencePolicy() { - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - OtpId id = OtpId.parse("otp_" + "A".repeat(22)); - repository.save( - OTP.issue( - id, - customer("A"), - new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - hasher.hash("123456"), - null, - 5, - now.minusSeconds(600), - now.minusSeconds(300), - now.plusSeconds(1097L * 86_400), - 0, - OtpStatus.ACTIVE, - null))); - var wrongOwner = - new OtpVerificationCommand( - id, customer("B"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); - assertThrows( - dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException.class, - () -> service.verify(wrongOwner)); - assertEquals(OtpStatus.ACTIVE, repository.findById(id).orElseThrow().getSnapshot().status()); - var expired = - new OtpVerificationCommand( - id, customer("A"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); - assertEquals( - OtpVerificationResult.EXPIRED, - assertThrows(OtpVerificationException.class, () -> service.verify(expired)).result()); - assertEquals(OtpStatus.EXPIRED, repository.findById(id).orElseThrow().getSnapshot().status()); - assertEquals(0, repository.findById(id).orElseThrow().getSnapshot().attempts()); - } - - private java.util.List verifyTogether(OtpId id, String code, int callers) - throws Exception { - var barrier = new java.util.concurrent.CyclicBarrier(callers); - OtpRepository racing = - new OtpRepository() { - @Override - public void save(OTP otp) { - repository.save(otp); - } - - @Override - public void replaceActive(OTP otp) { - repository.replaceActive(otp); - } - - @Override - public java.util.Optional findById(OtpId key) { - return repository.findById(key); - } - - @Override - public java.util.Optional withLockedChallenge( - OtpId key, java.util.function.Function mutation) { - /* All callers have read the original snapshot before any transaction acquires its row lock. */ - await(barrier); - return repository.withLockedChallenge(key, mutation); - } - }; - OtpApplicationService competing = - new OtpApplicationService( - racing, - hasher, - codes, - (subject, value, at) -> false, - message -> {}, - quota, - () -> id, - events, - audit, - java.time.Clock.systemUTC(), - java.time.Duration.ofMinutes(5), - java.time.Duration.ofSeconds(30), - java.time.Duration.ofDays(1097), - 5); - try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { - var futures = new java.util.ArrayList>(); - for (int caller = 0; caller < callers; caller++) - futures.add( - executor.submit( - () -> { - try { - competing.verify( - new OtpVerificationCommand( - id, customer("A"), OtpPurpose.LOGIN, code, null, "127.0.0.1")); - return OtpVerificationResult.VERIFIED; - } catch (OtpVerificationException exception) { - return exception.result(); - } - })); - var results = new java.util.ArrayList(); - for (var future : futures) results.add(future.get(15, java.util.concurrent.TimeUnit.SECONDS)); - return results; - } - } - - @Test - void restoredStatesNullableTotpHashesAndCaseDistinctIdentifiersRoundTrip() { - Instant created = - Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS).minusSeconds(600); - OtpStatus[] statuses = {OtpStatus.USED, OtpStatus.LOCKED, OtpStatus.EXPIRED, OtpStatus.ACTIVE}; - String[] suffixes = {"A", "a", "B", "b"}; - for (int index = 0; index < statuses.length; index++) { - OtpStatus status = statuses[index]; - var snapshot = - new OtpSnapshot( - OtpPurpose.LOGIN, - OtpChannel.AUTHENTICATOR_APP, - OtpVerificationMode.TOTP, - null, - null, - 5, - created, - created.plusSeconds(300), - created.plusSeconds(1097L * 86_400), - status == OtpStatus.LOCKED ? 5 : 1, - status, - status == OtpStatus.USED ? created.plusSeconds(1) : null); - OtpId id = OtpId.parse("otp_" + suffixes[index].repeat(22)); - CustomerId subject = customer(suffixes[index]); - repository.save(OTP.restore(id, subject, snapshot)); - var restored = repository.findById(id).orElseThrow(); - assertEquals(subject, restored.getCustomerId()); - assertEquals(snapshot, restored.getSnapshot()); - } - assertEquals(4, jdbc.queryForObject("SELECT count(*) FROM identity_challenge", Integer.class)); - } - - @Test - void challengeExpiringBetweenInitialReadAndLockedMutationIsRejectedAtExactBoundary() { - OTP otp = challenge("A", customer("A"), OtpPurpose.LOGIN, hasher.hash("123456")); - repository.save(otp); - Instant expires = otp.getSnapshot().expiresAt(); - var reads = new java.util.concurrent.atomic.AtomicInteger(); - java.time.Clock advancing = - new java.time.Clock() { - @Override - public java.time.ZoneId getZone() { - return java.time.ZoneOffset.UTC; - } - - @Override - public java.time.Clock withZone(java.time.ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return reads.getAndIncrement() == 0 ? expires.minusSeconds(1) : expires; - } - }; - var boundary = - new OtpApplicationService( - repository, - hasher, - codes, - (subject, code, at) -> false, - message -> {}, - quota, - otp::getId, - events, - audit, - advancing, - java.time.Duration.ofMinutes(5), - java.time.Duration.ofSeconds(30), - java.time.Duration.ofDays(1097), - 5); - var command = - new OtpVerificationCommand( - otp.getId(), customer("A"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); - assertEquals( - OtpVerificationResult.EXPIRED, - assertThrows(OtpVerificationException.class, () -> boundary.verify(command)).result()); - var restored = repository.findById(otp.getId()).orElseThrow().getSnapshot(); - assertEquals(OtpStatus.EXPIRED, restored.status()); - assertEquals(0, restored.attempts()); - } - - private static void await(java.util.concurrent.CyclicBarrier barrier) { - try { - barrier.await(10, java.util.concurrent.TimeUnit.SECONDS); - } catch (Exception exception) { - throw new IllegalStateException( - "Competing request did not reach its test barrier", exception); - } - } - - private static CustomerId customer(String suffix) { - return CustomerId.parse("cus_" + suffix.repeat(22)); - } - - private static OTP challenge( - String suffix, CustomerId customer, OtpPurpose purpose, String hash) { - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); - return OTP.issue( - OtpId.parse("otp_" + suffix.repeat(22)), - customer, - new OtpSnapshot( - purpose, - OtpChannel.SMS, - OtpVerificationMode.RANDOM_NUMERIC, - hash, - null, - 5, - now, - now.plusSeconds(300), - now.plusSeconds(1097L * 86_400), - 0, - OtpStatus.ACTIVE, - null)); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java deleted file mode 100644 index a7c077e..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/delivery/DeliveryAdaptersTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.delivery; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import org.junit.jupiter.api.Test; - -class DeliveryAdaptersTest { - @Test - void mockRemainsLocalAndUnconfiguredProviderModesFailExplicitly() { - var request = - new OtpDeliveryRequest( - OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - OtpChannel.SMS, - "phone-ref", - "123456"); - assertDoesNotThrow(() -> new MockDeliveryAdapter().submit(request)); - assertThrows( - UnsupportedOperationException.class, () -> new SmsDeliveryAdapter().submit(request)); - assertThrows( - UnsupportedOperationException.class, () -> new EmailDeliveryAdapter().submit(request)); - assertThrows( - UnsupportedOperationException.class, () -> new PushDeliveryAdapter().submit(request)); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java deleted file mode 100644 index e8b0380..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisherTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.kafka; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; -import dev.amg.payguard.identity.verification.domain.event.OtpEvent; -import java.time.Instant; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.kafka.core.KafkaTemplate; -import tools.jackson.databind.ObjectMapper; - -class KafkaOtpEventPublisherTest { - @Test - @SuppressWarnings("unchecked") - void convertsTypedIdsToExistingWireFields() { - KafkaTemplate kafka = mock(KafkaTemplate.class); - OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); - var publisher = new KafkaOtpEventPublisher(kafka, new ObjectMapper(), "identity.events.v1"); - publisher.publish( - new OtpEvent( - "identity.verified", - id, - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - OtpChannel.SMS, - OtpVerificationResult.VERIFIED, - Instant.parse("2026-01-01T00:00:00Z"), - "verified")); - var json = ArgumentCaptor.forClass(String.class); - verify(kafka).send(eq("identity.events.v1"), eq(id.value()), json.capture()); - assertTrue(json.getValue().contains("\"challengeId\":\"otp_ABCDEFGHIJKLMNOPQRSTUV\"")); - assertTrue(json.getValue().contains("\"userId\":\"cus_ABCDEFGHIJKLMNOPQRSTUV\"")); - assertFalse(json.getValue().contains("customerId")); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java deleted file mode 100644 index 32a5763..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.security; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class HmacOtpCodeHasherTest { - - private final HmacOtpCodeHasher hasher = - new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars"); - - @Test - void storesSaltedHashAndMatchesOnlyOriginalValue() { - String encoded = hasher.hash("123456"); - - assertNotEquals("123456", encoded); - assertTrue(hasher.matches("123456", encoded)); - assertFalse(hasher.matches("123457", encoded)); - assertFalse(hasher.matches("123456", "not-a-hash")); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java deleted file mode 100644 index 3e3f2c0..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/TotpVerificationTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.security; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import java.time.Instant; -import java.util.Optional; -import org.junit.jupiter.api.Test; - -class TotpVerificationTest { - private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); - - @Test - void verifiesKnownRfc6238Sha1VectorWithExistingSixDigitFormat() { - var verifier = - new TotpAuthenticatorCodeVerifier( - customer -> Optional.of("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")); - assertTrue(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); - assertFalse(verifier.verify(CUSTOMER, "000000", Instant.ofEpochSecond(59))); - assertFalse(verifier.verify(CUSTOMER, "287082", Instant.ofEpochSecond(300))); - assertFalse(verifier.verify(CUSTOMER, "2870820", Instant.ofEpochSecond(59))); - } - - @Test - void missingOrInvalidSecretsFailClosed() { - var missing = new TotpAuthenticatorCodeVerifier(new UnavailableAuthenticatorSecretAdapter()); - assertFalse(missing.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); - var invalid = new TotpAuthenticatorCodeVerifier(customer -> Optional.of("invalid!")); - assertFalse(invalid.verify(CUSTOMER, "287082", Instant.ofEpochSecond(59))); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java deleted file mode 100644 index 0ebee7f..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java +++ /dev/null @@ -1,151 +0,0 @@ -package dev.amg.payguard.identity.verification.infrastructure.security; - -import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import infrastructure.vault.VaultClient; -import infrastructure.vault.VaultUnavailableException; -import java.net.URI; -import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** Real scoped KV reads and key compatibility against the isolated TLS rehearsal Vault. */ -class VaultSecretsIT { - private static final Path LOCAL = Path.of("/workspace/.local/payguard"); - private static final String KEYS = "secret/data/local/identity/otp-keys"; - private static final String FIRST = "synthetic-first-pepper-marker-123456789"; - private static final String SECOND = "synthetic-second-pepper-marker-123456789"; - - @Test - void provisionedSeedIsCustomerScopedAndMissingIsDistinctFromDenied() { - assertEquals("true", System.getenv("PAYGUARD_REHEARSAL")); - CustomerId customer = - CustomerId.parse( - "cus_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 22)); - String path = "secret/data/local/identity/authenticators/" + customer.getValue(); - try (VaultClient operator = client("operator/approle"); - VaultClient identity = client("apps/identity"); - VaultClient wallet = client("apps/wallet")) { - operator.write(path, Map.of("data", Map.of("seed", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"))); - try { - var adapter = new VaultAuthenticatorSecretAdapter(identity, "local/identity"); - assertTrue(adapter.secretFor(customer).isPresent()); - assertTrue(adapter.secretFor(CustomerId.parse("cus_" + "R".repeat(22))).isEmpty()); - var totp = new TotpAuthenticatorCodeVerifier(adapter); - assertTrue(totp.verify(customer, "287082", Instant.ofEpochSecond(59))); - assertFalse(totp.verify(customer, "287083", Instant.ofEpochSecond(59))); - var denied = new VaultAuthenticatorSecretAdapter(wallet, "local/identity"); - var failure = - assertThrows(VaultUnavailableException.class, () -> denied.secretFor(customer)); - assertFalse(failure.getMessage().contains("GEZDGNBV")); - } finally { - operator.write( - "secret/destroy/local/identity/authenticators/" + customer.getValue(), - Map.of("versions", new int[] {1})); - } - } - } - - @Test - void actualKvRotationRetainsActiveHashesRejectsEarlyRetirementAndEventuallyRetiresOldKey() { - try (VaultClient operator = client("operator/approle"); - VaultClient identity = client("apps/identity")) { - var original = operator.read(KEYS).path("data").path("data"); - operator.write( - KEYS, - Map.of( - "data", - Map.of("current", "probeA", "keys", Map.of("probeA", FIRST), "legacy", "probeA"))); - try (var keys = - new VaultPepperKeys( - identity, Duration.ofSeconds(1), Duration.ofSeconds(1), "local/identity")) { - var hasher = new HmacOtpCodeHasher(keys); - String old = hasher.hash("synthetic-code"); - String legacy = old.substring(old.indexOf(':') + 1); - assertTrue(hasher.matches("synthetic-code", legacy)); - operator.write( - KEYS, - Map.of( - "data", - Map.of( - "current", - "probeB", - "keys", - Map.of("probeA", FIRST, "probeB", SECOND), - "legacy", - "probeA"))); - await().atMost(Duration.ofSeconds(8)).until(() -> keys.get().current().equals("probeB")); - String newer = hasher.hash("synthetic-code"); - assertTrue(newer.startsWith("probeB:")); - assertTrue(hasher.matches("synthetic-code", old)); - assertTrue(hasher.matches("synthetic-code", legacy)); - operator.write( - KEYS, - Map.of( - "data", - Map.of( - "current", - "probeB", - "keys", - Map.of("probeA", FIRST, "probeB", SECOND), - "legacy", - "probeB"))); - await().atMost(Duration.ofSeconds(8)).until(() -> !keys.refreshHealthy()); - assertTrue(hasher.matches("synthetic-code", legacy)); - operator.write( - KEYS, - Map.of( - "data", - Map.of("current", "probeB", "keys", Map.of("probeA", FIRST, "probeB", SECOND)))); - Instant removal = Instant.now(); - await() - .atMost(Duration.ofSeconds(8)) - .until(() -> Instant.now().isAfter(removal.plusSeconds(6))); - assertTrue(hasher.matches("synthetic-code", old)); - assertTrue(hasher.matches("synthetic-code", legacy)); - operator.write( - KEYS, Map.of("data", Map.of("current", "probeB", "keys", Map.of("probeB", SECOND)))); - await() - .atMost(Duration.ofSeconds(14)) - .until(() -> !keys.get().keys().containsKey("probeA")); - assertFalse(hasher.matches("synthetic-code", old)); - assertFalse(hasher.matches("synthetic-code", legacy)); - assertTrue(hasher.matches("synthetic-code", newer)); - assertFalse(keys.get().toString().contains(SECOND)); - } finally { - operator.write(KEYS, Map.of("data", original)); - } - } - } - - @Test - void invalidMissingKeyConfigurationFailsStartupWithoutDefault() { - try (VaultClient wallet = client("apps/wallet")) { - assertThrows( - VaultUnavailableException.class, - () -> - new VaultPepperKeys( - wallet, Duration.ofMinutes(5), Duration.ofSeconds(30), "local/identity")); - } - } - - private static VaultClient client(String folder) { - String scope = - folder.equals("operator/approle") - ? "OPERATOR" - : folder.substring(5).toUpperCase(java.util.Locale.ROOT); - String tokenFile = System.getenv("PAYGUARD_REHEARSAL_" + scope + "_TOKEN_FILE"); - if (tokenFile == null || tokenFile.isBlank()) { - throw new IllegalStateException("Agent token fixture path required for " + scope); - } - return new VaultClient( - URI.create("https://vault:8200"), LOCAL.resolve("ca.crt"), Path.of(tokenFile)); - } -} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java deleted file mode 100644 index e1b916c..0000000 --- a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpControllerTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package dev.amg.payguard.identity.verification.interfaces.rest; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; -import dev.amg.payguard.identity.customer.domain.value_object.OtpId; -import dev.amg.payguard.identity.verification.application.IssueOtpCommand; -import dev.amg.payguard.identity.verification.application.IssuedOtp; -import dev.amg.payguard.identity.verification.application.OtpApplicationService; -import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; -import dev.amg.payguard.identity.verification.application.VerifiedOtp; -import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; -import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; -import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; -import jakarta.servlet.http.HttpServletRequest; -import java.time.Instant; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -class OtpControllerTest { - @Test - void parsesCustomerAndChallengeAtBoundaryAndReturnsStringIds() { - var service = mock(OtpApplicationService.class); - var request = mock(HttpServletRequest.class); - when(request.getRemoteAddr()).thenReturn("127.0.0.1"); - OtpId id = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); - CustomerId customer = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); - Instant now = Instant.parse("2026-01-01T00:00:00Z"); - when(service.issue(any())).thenReturn(new IssuedOtp(id, OtpPurpose.LOGIN, OtpChannel.SMS, now)); - when(service.verify(any())).thenReturn(new VerifiedOtp(id, OtpPurpose.LOGIN, now)); - var controller = new OtpController(service); - assertEquals( - id.value(), - controller - .issue( - new OtpController.IssueOtpRequest( - customer.getValue(), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null), - request) - .getBody() - .challengeId()); - assertEquals( - id.value(), - controller - .verify( - id.value(), - new OtpController.VerifyOtpRequest( - customer.getValue(), OtpPurpose.LOGIN, "123456", null), - request) - .getBody() - .challengeId()); - var issue = ArgumentCaptor.forClass(IssueOtpCommand.class); - verify(service).issue(issue.capture()); - assertEquals(customer, issue.getValue().customerId()); - var verification = ArgumentCaptor.forClass(OtpVerificationCommand.class); - verify(service).verify(verification.capture()); - assertEquals(id, verification.getValue().challengeId()); - assertEquals(customer, verification.getValue().customerId()); - } - - @Test - void rejectsMalformedIdsBeforeCallingApplication() { - var service = mock(OtpApplicationService.class); - var controller = new OtpController(service); - assertThrows( - IllegalArgumentException.class, - () -> - controller.verify( - "invalid", - new OtpController.VerifyOtpRequest( - "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null), - mock(HttpServletRequest.class))); - verifyNoInteractions(service); - } - - @Test - void diagnosticOutputDoesNotExposeCodeButPayloadStillContainsIt() { - var request = - new OtpController.VerifyOtpRequest( - "cus_ABCDEFGHIJKLMNOPQRSTUV", OtpPurpose.LOGIN, "123456", null); - assertEquals("123456", request.code()); - assertFalse(request.toString().contains("123456")); - assertFalse( - new OtpVerificationCommand( - OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"), - CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"), - OtpPurpose.LOGIN, - "123456", - null, - null) - .toString() - .contains("123456")); - assertFalse( - new VerifyOtpCommand(OtpPurpose.LOGIN, "123456", null, Instant.now(), false) - .toString() - .contains("123456")); - } -} diff --git a/payguard-identity-service/Dockerfile b/payguard-identity/Dockerfile similarity index 94% rename from payguard-identity-service/Dockerfile rename to payguard-identity/Dockerfile index fd90f97..14a7838 100644 --- a/payguard-identity-service/Dockerfile +++ b/payguard-identity/Dockerfile @@ -10,4 +10,4 @@ RUN addgroup -S payguard && adduser -S payguard -G payguard COPY --from=build /workspace/payguard-identity-service/target/payguard-identity-service-*.jar /app/app.jar USER payguard EXPOSE 8084 -ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"] +ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/payguard-identity-service/README.md b/payguard-identity/README.md similarity index 89% rename from payguard-identity-service/README.md rename to payguard-identity/README.md index 3a4827b..7be3824 100644 --- a/payguard-identity-service/README.md +++ b/payguard-identity/README.md @@ -5,7 +5,7 @@ Identity contains access, customer, verification and registration modules. Verif ## Existing API - `POST /identity/challenges` issues a challenge for a case-sensitive `cus_...` customer in the existing `userId` field. -- `POST /identity/challenges/{challengeId}/verify` verifies the code and returns `challengeId`, `purpose` and `verifiedAt`. This result is not an authorization credential. The retired step-up-token endpoint remains absent. +- `POST /identity/challenges/{challengeId}/verify` verifies the code and returns `otpId`, `purpose` and `verifiedAt`. This result is not an authorization credential. The retired step-up-token endpoint remains absent. Random codes use salted, versioned HMAC-SHA256 hashes. Vault KV v2 supplies the named key ring; an explicitly named legacy key is required for old `salt:digest` records. Rotation retains old keys throughout the configured OTP compatibility window. Device-binding hashes use the same versioned contract. @@ -17,7 +17,7 @@ PostgreSQL stores prefixed challenge/customer strings unchanged, UUID audit IDs Issuance locks a durable customer/purpose scope, including first issuance, and a partial unique index permits one ACTIVE row per scope. Other customers and purposes remain independent. Remote Vault, delivery, quota, audit and Kafka operations do not belong to the challenge transaction. Audit/event/delivery failures can still follow committed state; durable delivery is separate work. -Runtime credentials come from a service-scoped Vault AppRole and a rotating pool. Flyway runs separately with migration privileges; Hibernate validates the migrated mapping. Oracle V1/V2 histories remain unchanged under `db/legacy/oracle`. Populated legacy UUID records require an approved mapping before migration. +Runtime credentials come from a service-scoped Vault AppRole and a rotating pool. Flyway runs separately with migration privileges; Hibernate validates the migrated mapping. Populated legacy UUID records require an approved mapping before migration. ## Verification and operations diff --git a/payguard-identity-service/pom.xml b/payguard-identity/pom.xml similarity index 53% rename from payguard-identity-service/pom.xml rename to payguard-identity/pom.xml index 1ac4088..6f9c237 100644 --- a/payguard-identity-service/pom.xml +++ b/payguard-identity/pom.xml @@ -1,7 +1,9 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 + https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 @@ -10,11 +12,13 @@ 1.0.0-SNAPSHOT - payguard-identity-service + payguard-identity PayGuard Identity Service + 2.1.1 + @@ -28,84 +32,116 @@ + + org.springframework.boot spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-webmvc + org.springframework.boot spring-boot-starter-validation + + org.springframework.boot - spring-boot-starter-jackson + spring-boot-starter-jooq + + + org.postgresql + postgresql + runtime + + + org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-flyway + test + + + org.flywaydb + flyway-database-postgresql + test + + + org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot spring-boot-starter-kafka + + - org.springframework.boot - spring-boot-starter-flyway - test + org.jspecify + jspecify + + - org.flywaydb - flyway-database-postgresql - test + org.springframework.modulith + spring-modulith-starter-core + + - org.postgresql - postgresql - runtime + dev.amg.payguard + payguard-shared + 1.0.0-SNAPSHOT + + org.springframework.boot spring-boot-starter-webmvc-test test + + + org.springframework.boot + spring-boot-starter-jooq-test + test + + org.testcontainers testcontainers-postgresql test + com.redis testcontainers-redis test + org.testcontainers testcontainers-junit-jupiter test - - dev.amg.payguard - payguard-shared - 1.0.0-SNAPSHOT - compile - - - org.springframework.modulith - spring-modulith-starter-core - + org.springframework.modulith spring-modulith-starter-test test + @@ -124,4 +160,70 @@ + + + jooq-codegen + + + + + org.jooq + jooq-codegen-maven + 3.21.7 + + + + generate-jooq + generate-sources + + generate + + + + + + + org.jooq + jooq-meta-extensions + 3.21.7 + + + + + + + org.jooq.meta.extensions.ddl.DDLDatabase + + + scripts + ${project.basedir}/src/main/resources/db/jooq/*.sql + + + sort + flyway + + + defaultNameCase + lower + + + + + PUBLIC + payguard + + + + + + dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated + ${project.basedir}/src/main/java + + + + + + + + diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/IdentityApp.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/IdentityApp.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/IdentityApp.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/AccountId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/AccountId.java new file mode 100644 index 0000000..416552f --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/AccountId.java @@ -0,0 +1,43 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validates syntax, not existence or authorization. */ +public final class AccountId implements ValueObject { + private static final String ACCOUNT_ID_PREFIX = "acc_"; + private final String identifier; + + private AccountId(String value) { + this.identifier = PrefixedIdValidator.validateAndReturn(value, ACCOUNT_ID_PREFIX); + } + + public static AccountId parse(String value) { + return new AccountId(value); + } + + public String value() { + return identifier; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other || (other instanceof AccountId that && identifier.equals(that.identifier)); + } + + @Override + public int hashCode() { + return Objects.hashCode(identifier); + } + + @Override + public String toString() { + return identifier; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherAccountId) { + return equals(otherAccountId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/MembershipId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/MembershipId.java new file mode 100644 index 0000000..5a74fae --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/MembershipId.java @@ -0,0 +1,44 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +public final class MembershipId implements ValueObject { + private static final String MEMBERSHIP_ID_PREFIX = "mem_"; + private final String identifier; + + private MembershipId(String value) { + this.identifier = PrefixedIdValidator.validateAndReturn(value, MEMBERSHIP_ID_PREFIX); + } + + public static MembershipId parse(String value) { + return new MembershipId(value); + } + + public String value() { + return identifier; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other + || (other instanceof MembershipId that && identifier.equals(that.identifier)); + } + + @Override + public int hashCode() { + return Objects.hashCode(identifier); + } + + @Override + public String toString() { + return identifier; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherMembershipId) { + return equals(otherMembershipId); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpAuditId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpAuditId.java new file mode 100644 index 0000000..ce10b42 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpAuditId.java @@ -0,0 +1,44 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +public final class OtpAuditId implements ValueObject { + private static final String AUDIT_ID_PREFIX = "oaud_"; + private final String identifier; + + private OtpAuditId(String value) { + this.identifier = PrefixedIdValidator.validateAndReturn(value, AUDIT_ID_PREFIX); + } + + public static OtpAuditId parse(String value) { + return new OtpAuditId(value); + } + + public String value() { + return identifier; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other + || (other instanceof OtpAuditId that && identifier.equals(that.identifier)); + } + + @Override + public int hashCode() { + return Objects.hashCode(identifier); + } + + @Override + public String toString() { + return identifier; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherAuditId) { + return equals(otherAuditId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/RegistrationId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/RegistrationId.java new file mode 100644 index 0000000..721ca70 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/RegistrationId.java @@ -0,0 +1,44 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +public final class RegistrationId implements ValueObject { + private static final String REGISTER_ID_PREFIX = "reg_"; + private final String identifier; + + private RegistrationId(String value) { + this.identifier = PrefixedIdValidator.validateAndReturn(value, REGISTER_ID_PREFIX); + } + + public static RegistrationId parse(String value) { + return new RegistrationId(value); + } + + public String value() { + return identifier; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other + || (other instanceof RegistrationId that && identifier.equals(that.identifier)); + } + + @Override + public int hashCode() { + return Objects.hashCode(identifier); + } + + @Override + public String toString() { + return identifier; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherRegisterId) { + return equals(otherRegisterId); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/TenantId.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/TenantId.java new file mode 100644 index 0000000..fe29d6e --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/TenantId.java @@ -0,0 +1,43 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** Immutable identifier. Parsing validateAndReturns syntax, not existence or authorization. */ +public final class TenantId implements ValueObject { + private static final String TENANT_ID_PREFIX = "ten_"; + private final String identifier; + + private TenantId(String value) { + this.identifier = PrefixedIdValidator.validateAndReturn(value, TENANT_ID_PREFIX); + } + + public static TenantId parse(String value) { + return new TenantId(value); + } + + public String value() { + return identifier; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other || (other instanceof TenantId that && identifier.equals(that.identifier)); + } + + @Override + public int hashCode() { + return Objects.hashCode(identifier); + } + + @Override + public String toString() { + return identifier; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherTenantId) { + return equals(otherTenantId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/DefaultCatalog.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/DefaultCatalog.java new file mode 100644 index 0000000..3a96813 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/DefaultCatalog.java @@ -0,0 +1,54 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated; + + +import java.util.Arrays; +import java.util.List; + +import org.jooq.Constants; +import org.jooq.Schema; +import org.jooq.impl.CatalogImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DefaultCatalog extends CatalogImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of DEFAULT_CATALOG + */ + public static final DefaultCatalog DEFAULT_CATALOG = new DefaultCatalog(); + + /** + * The schema payguard. + */ + public final Payguard PAYGUARD = Payguard.PAYGUARD; + + /** + * No further instances allowed + */ + private DefaultCatalog() { + super(""); + } + + @Override + public final List getSchemas() { + return Arrays.asList( + Payguard.PAYGUARD + ); + } + + /** + * A reference to the 3.21 minor release of the code generator. If this + * doesn't compile, it's because the runtime library uses an older minor + * release, namely: 3.21. You can turn off the generation of this reference + * by specifying /configuration/generator/generate/jooqVersionReference + */ + private static final String REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_21; +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Indexes.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Indexes.java new file mode 100644 index 0000000..4b544ac --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Indexes.java @@ -0,0 +1,32 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOutbox; + +import org.jooq.Index; +import org.jooq.OrderField; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; + + +/** + * A class modelling indexes of tables in payguard. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Indexes { + + // ------------------------------------------------------------------------- + // INDEX definitions + // ------------------------------------------------------------------------- + + public static final Index IX_IDENTITY_AUDIT_OTP = Internal.createIndex(DSL.name("ix_identity_audit_otp"), IdentityAuditLog.IDENTITY_AUDIT_LOG, new OrderField[] { IdentityAuditLog.IDENTITY_AUDIT_LOG.OTP_ID, IdentityAuditLog.IDENTITY_AUDIT_LOG.OCCURRED_AT }, false); + public static final Index IX_IDENTITY_AUDIT_SUBJECT_TIME = Internal.createIndex(DSL.name("ix_identity_audit_subject_time"), IdentityAuditLog.IDENTITY_AUDIT_LOG, new OrderField[] { IdentityAuditLog.IDENTITY_AUDIT_LOG.USER_ID, IdentityAuditLog.IDENTITY_AUDIT_LOG.OCCURRED_AT }, false); + public static final Index IX_IDENTITY_OTP_RETENTION = Internal.createIndex(DSL.name("ix_identity_otp_retention"), IdentityOtp.IDENTITY_OTP, new OrderField[] { IdentityOtp.IDENTITY_OTP.RETENTION_UNTIL }, false); + public static final Index IX_IDENTITY_OUTBOX_PENDING = Internal.createIndex(DSL.name("ix_identity_outbox_pending"), IdentityOutbox.IDENTITY_OUTBOX, new OrderField[] { IdentityOutbox.IDENTITY_OUTBOX.NEXT_ATTEMPT_AT, IdentityOutbox.IDENTITY_OUTBOX.CREATED_AT, IdentityOutbox.IDENTITY_OUTBOX.EVENT_ID }, false); + public static final Index UX_IDENTITY_OTP_ACTIVE = Internal.createIndex(DSL.name("ux_identity_otp_active"), IdentityOtp.IDENTITY_OTP, new OrderField[] { IdentityOtp.IDENTITY_OTP.USER_ID, IdentityOtp.IDENTITY_OTP.PURPOSE }, true); +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Keys.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Keys.java new file mode 100644 index 0000000..9c770f7 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Keys.java @@ -0,0 +1,45 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtpScope; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOutbox; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityAuditLogRecord; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOtpRecord; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOtpScopeRecord; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOutboxRecord; + +import org.jooq.ForeignKey; +import org.jooq.TableField; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.QOM.ForeignKeyRule; + + +/** + * A class modelling foreign key relationships and constraints of tables in + * payguard. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Keys { + + // ------------------------------------------------------------------------- + // UNIQUE and PRIMARY KEY definitions + // ------------------------------------------------------------------------- + + public static final UniqueKey CONSTRAINT_7 = Internal.createUniqueKey(IdentityAuditLog.IDENTITY_AUDIT_LOG, DSL.name("CONSTRAINT_7"), new TableField[] { IdentityAuditLog.IDENTITY_AUDIT_LOG.ID }, true); + public static final UniqueKey CONSTRAINT_F = Internal.createUniqueKey(IdentityOtp.IDENTITY_OTP, DSL.name("CONSTRAINT_F"), new TableField[] { IdentityOtp.IDENTITY_OTP.ID }, true); + public static final UniqueKey CONSTRAINT_9 = Internal.createUniqueKey(IdentityOtpScope.IDENTITY_OTP_SCOPE, DSL.name("CONSTRAINT_9"), new TableField[] { IdentityOtpScope.IDENTITY_OTP_SCOPE.USER_ID, IdentityOtpScope.IDENTITY_OTP_SCOPE.PURPOSE }, true); + public static final UniqueKey CONSTRAINT_D = Internal.createUniqueKey(IdentityOutbox.IDENTITY_OUTBOX, DSL.name("CONSTRAINT_D"), new TableField[] { IdentityOutbox.IDENTITY_OUTBOX.EVENT_ID }, true); + + // ------------------------------------------------------------------------- + // FOREIGN KEY definitions + // ------------------------------------------------------------------------- + + public static final ForeignKey CONSTRAINT_78 = Internal.createForeignKey(IdentityAuditLog.IDENTITY_AUDIT_LOG, DSL.name("CONSTRAINT_78"), new TableField[] { IdentityAuditLog.IDENTITY_AUDIT_LOG.OTP_ID }, Keys.CONSTRAINT_F, new TableField[] { IdentityOtp.IDENTITY_OTP.ID }, true, ForeignKeyRule.NO_ACTION, ForeignKeyRule.NO_ACTION); +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Payguard.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Payguard.java new file mode 100644 index 0000000..ef43cd2 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Payguard.java @@ -0,0 +1,76 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtpScope; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOutbox; + +import java.util.Arrays; +import java.util.List; + +import org.jooq.Catalog; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SchemaImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Payguard extends SchemaImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of payguard + */ + public static final Payguard PAYGUARD = new Payguard(); + + /** + * The table payguard.identity_audit_log. + */ + public final IdentityAuditLog IDENTITY_AUDIT_LOG = IdentityAuditLog.IDENTITY_AUDIT_LOG; + + /** + * The table payguard.identity_otp. + */ + public final IdentityOtp IDENTITY_OTP = IdentityOtp.IDENTITY_OTP; + + /** + * The table payguard.identity_otp_scope. + */ + public final IdentityOtpScope IDENTITY_OTP_SCOPE = IdentityOtpScope.IDENTITY_OTP_SCOPE; + + /** + * The table payguard.identity_outbox. + */ + public final IdentityOutbox IDENTITY_OUTBOX = IdentityOutbox.IDENTITY_OUTBOX; + + /** + * No further instances allowed + */ + private Payguard() { + super(DSL.name("payguard"), null, DSL.comment("")); + } + + + @Override + public Catalog getCatalog() { + return DefaultCatalog.DEFAULT_CATALOG; + } + + @Override + public final List> getTables() { + return Arrays.asList( + IdentityAuditLog.IDENTITY_AUDIT_LOG, + IdentityOtp.IDENTITY_OTP, + IdentityOtpScope.IDENTITY_OTP_SCOPE, + IdentityOutbox.IDENTITY_OUTBOX + ); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Tables.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Tables.java new file mode 100644 index 0000000..103f98c --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/Tables.java @@ -0,0 +1,38 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtpScope; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOutbox; + + +/** + * Convenience access to all tables in payguard. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Tables { + + /** + * The table payguard.identity_audit_log. + */ + public static final IdentityAuditLog IDENTITY_AUDIT_LOG = IdentityAuditLog.IDENTITY_AUDIT_LOG; + + /** + * The table payguard.identity_otp. + */ + public static final IdentityOtp IDENTITY_OTP = IdentityOtp.IDENTITY_OTP; + + /** + * The table payguard.identity_otp_scope. + */ + public static final IdentityOtpScope IDENTITY_OTP_SCOPE = IdentityOtpScope.IDENTITY_OTP_SCOPE; + + /** + * The table payguard.identity_outbox. + */ + public static final IdentityOutbox IDENTITY_OUTBOX = IdentityOutbox.IDENTITY_OUTBOX; +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/enums/IdentityStatus.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/enums/IdentityStatus.java new file mode 100644 index 0000000..b0c2fce --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/enums/IdentityStatus.java @@ -0,0 +1,64 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.enums; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Payguard; + +import org.jooq.Catalog; +import org.jooq.EnumType; +import org.jooq.Schema; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public enum IdentityStatus implements EnumType { + + ACTIVE("ACTIVE"), + + USED("USED"), + + LOCKED("LOCKED"), + + EXPIRED("EXPIRED"), + + INVALIDATED("INVALIDATED"); + + private final String literal; + + private IdentityStatus(String literal) { + this.literal = literal; + } + + @Override + public Catalog getCatalog() { + return getSchema().getCatalog(); + } + + @Override + public Schema getSchema() { + return Payguard.PAYGUARD; + } + + @Override + public String getName() { + return "identity_status"; + } + + @Override + public String getLiteral() { + return literal; + } + + /** + * Lookup a value of this EnumType by its literal. Returns + * null, if no such value could be found, see {@link + * EnumType#lookupLiteral(Class, String)}. + */ + public static IdentityStatus lookupLiteral(String literal) { + return EnumType.lookupLiteral(IdentityStatus.class, literal); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityAuditLog.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityAuditLog.java new file mode 100644 index 0000000..f38f8bc --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityAuditLog.java @@ -0,0 +1,318 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Indexes; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Keys; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Payguard; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp.IdentityOtpPath; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityAuditLogRecord; + +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Index; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableLike; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityAuditLog extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of payguard.identity_audit_log + */ + public static final IdentityAuditLog IDENTITY_AUDIT_LOG = new IdentityAuditLog(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return IdentityAuditLogRecord.class; + } + + /** + * The column payguard.identity_audit_log.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.UUID.nullable(false).defaultValue(DSL.field(DSL.raw("RANDOM_UUID()"), SQLDataType.UUID)), this, ""); + + /** + * The column payguard.identity_audit_log.otp_id. + */ + public final TableField OTP_ID = createField(DSL.name("otp_id"), SQLDataType.VARCHAR(26).nullable(false), this, ""); + + /** + * The column payguard.identity_audit_log.user_id. + */ + public final TableField USER_ID = createField(DSL.name("user_id"), SQLDataType.VARCHAR(255).nullable(false), this, ""); + + /** + * The column payguard.identity_audit_log.action. + */ + public final TableField ACTION = createField(DSL.name("action"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + /** + * The column payguard.identity_audit_log.reason. + */ + public final TableField REASON = createField(DSL.name("reason"), SQLDataType.VARCHAR(128).nullable(false), this, ""); + + /** + * The column payguard.identity_audit_log.source_ip. + */ + public final TableField SOURCE_IP = createField(DSL.name("source_ip"), SQLDataType.VARCHAR(64), this, ""); + + /** + * The column payguard.identity_audit_log.occurred_at. + */ + public final TableField OCCURRED_AT = createField(DSL.name("occurred_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false), this, ""); + + private IdentityAuditLog(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private IdentityAuditLog(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased payguard.identity_audit_log table + * reference + */ + public IdentityAuditLog(String alias) { + this(DSL.name(alias), IDENTITY_AUDIT_LOG); + } + + /** + * Create an aliased payguard.identity_audit_log table + * reference + */ + public IdentityAuditLog(Name alias) { + this(alias, IDENTITY_AUDIT_LOG); + } + + /** + * Create a payguard.identity_audit_log table reference + */ + public IdentityAuditLog() { + this(DSL.name("identity_audit_log"), null); + } + + public IdentityAuditLog(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, IDENTITY_AUDIT_LOG); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class IdentityAuditLogPath extends IdentityAuditLog implements Path { + + private static final long serialVersionUID = 1L; + public IdentityAuditLogPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private IdentityAuditLogPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public IdentityAuditLogPath as(String alias) { + return new IdentityAuditLogPath(DSL.name(alias), this); + } + + @Override + public IdentityAuditLogPath as(Name alias) { + return new IdentityAuditLogPath(alias, this); + } + + @Override + public IdentityAuditLogPath as(Table alias) { + return new IdentityAuditLogPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : Payguard.PAYGUARD; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.IX_IDENTITY_AUDIT_OTP, Indexes.IX_IDENTITY_AUDIT_SUBJECT_TIME); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.CONSTRAINT_7; + } + + @Override + public List> getReferences() { + return Arrays.asList(Keys.CONSTRAINT_78); + } + + private transient IdentityOtpPath _identityOtp; + + /** + * Get the implicit join path to the payguard.identity_otp + * table. + */ + public IdentityOtpPath identityOtp() { + if (_identityOtp == null) + _identityOtp = new IdentityOtpPath(this, Keys.CONSTRAINT_78, null); + + return _identityOtp; + } + + @Override + public IdentityAuditLog as(String alias) { + return new IdentityAuditLog(DSL.name(alias), this); + } + + @Override + public IdentityAuditLog as(Name alias) { + return new IdentityAuditLog(alias, this); + } + + @Override + public IdentityAuditLog as(Table alias) { + return new IdentityAuditLog(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public IdentityAuditLog rename(String name) { + return new IdentityAuditLog(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public IdentityAuditLog rename(Name name) { + return new IdentityAuditLog(name, null); + } + + /** + * Rename this table + */ + @Override + public IdentityAuditLog rename(Table name) { + return new IdentityAuditLog(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog where(Condition condition) { + return new IdentityAuditLog(getQualifiedName(), aliased() ? this : null, null, Internal.condition(this, condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityAuditLog where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityAuditLog where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityAuditLog where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityAuditLog where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog whereExists(TableLike select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityAuditLog whereNotExists(TableLike select) { + return where(DSL.notExists(select)); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtp.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtp.java new file mode 100644 index 0000000..269e439 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtp.java @@ -0,0 +1,362 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Indexes; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Keys; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Payguard; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.enums.IdentityStatus; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog.IdentityAuditLogPath; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOtpRecord; + +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.jooq.Check; +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.ForeignKey; +import org.jooq.Index; +import org.jooq.InverseForeignKey; +import org.jooq.Name; +import org.jooq.Path; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.Record; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableLike; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOtp extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of payguard.identity_otp + */ + public static final IdentityOtp IDENTITY_OTP = new IdentityOtp(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return IdentityOtpRecord.class; + } + + /** + * The column payguard.identity_otp.id. + */ + public final TableField ID = createField(DSL.name("id"), SQLDataType.VARCHAR(26).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.user_id. + */ + public final TableField USER_ID = createField(DSL.name("user_id"), SQLDataType.VARCHAR(255).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.purpose. + */ + public final TableField PURPOSE = createField(DSL.name("purpose"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.channel. + */ + public final TableField CHANNEL = createField(DSL.name("channel"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.verification_mode. + */ + public final TableField VERIFICATION_MODE = createField(DSL.name("verification_mode"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.code_hash. + */ + public final TableField CODE_HASH = createField(DSL.name("code_hash"), SQLDataType.VARCHAR(512), this, ""); + + /** + * The column payguard.identity_otp.device_binding_hash. + */ + public final TableField DEVICE_BINDING_HASH = createField(DSL.name("device_binding_hash"), SQLDataType.VARCHAR(512), this, ""); + + /** + * The column payguard.identity_otp.attempts. + */ + public final TableField ATTEMPTS = createField(DSL.name("attempts"), SQLDataType.INTEGER.nullable(false).defaultValue(DSL.field(DSL.raw("0"), SQLDataType.INTEGER)), this, ""); + + /** + * The column payguard.identity_otp.max_attempts. + */ + public final TableField MAX_ATTEMPTS = createField(DSL.name("max_attempts"), SQLDataType.INTEGER.nullable(false), this, ""); + + /** + * The column payguard.identity_otp.status. + */ + public final TableField STATUS = createField(DSL.name("status"), SQLDataType.VARCHAR.nullable(false).asEnumDataType(IdentityStatus.class), this, ""); + + /** + * The column payguard.identity_otp.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.expires_at. + */ + public final TableField EXPIRES_AT = createField(DSL.name("expires_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false), this, ""); + + /** + * The column payguard.identity_otp.consumed_at. + */ + public final TableField CONSUMED_AT = createField(DSL.name("consumed_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, ""); + + /** + * The column payguard.identity_otp.retention_until. + */ + public final TableField RETENTION_UNTIL = createField(DSL.name("retention_until"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false), this, ""); + + private IdentityOtp(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private IdentityOtp(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased payguard.identity_otp table reference + */ + public IdentityOtp(String alias) { + this(DSL.name(alias), IDENTITY_OTP); + } + + /** + * Create an aliased payguard.identity_otp table reference + */ + public IdentityOtp(Name alias) { + this(alias, IDENTITY_OTP); + } + + /** + * Create a payguard.identity_otp table reference + */ + public IdentityOtp() { + this(DSL.name("identity_otp"), null); + } + + public IdentityOtp(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath, IDENTITY_OTP); + } + + /** + * A subtype implementing {@link Path} for simplified path-based joins. + */ + public static class IdentityOtpPath extends IdentityOtp implements Path { + + private static final long serialVersionUID = 1L; + public IdentityOtpPath(Table path, ForeignKey childPath, InverseForeignKey parentPath) { + super(path, childPath, parentPath); + } + private IdentityOtpPath(Name alias, Table aliased) { + super(alias, aliased); + } + + @Override + public IdentityOtpPath as(String alias) { + return new IdentityOtpPath(DSL.name(alias), this); + } + + @Override + public IdentityOtpPath as(Name alias) { + return new IdentityOtpPath(alias, this); + } + + @Override + public IdentityOtpPath as(Table alias) { + return new IdentityOtpPath(alias.getQualifiedName(), this); + } + } + + @Override + public Schema getSchema() { + return aliased() ? null : Payguard.PAYGUARD; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.IX_IDENTITY_OTP_RETENTION, Indexes.UX_IDENTITY_OTP_ACTIVE); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.CONSTRAINT_F; + } + + private transient IdentityAuditLogPath _identityAuditLog; + + /** + * Get the implicit to-many join path to the + * payguard.identity_audit_log table + */ + public IdentityAuditLogPath identityAuditLog() { + if (_identityAuditLog == null) + _identityAuditLog = new IdentityAuditLogPath(this, null, Keys.CONSTRAINT_78.getInverseKey()); + + return _identityAuditLog; + } + + @Override + public List> getChecks() { + return Arrays.asList( + Internal.createCheck(this, DSL.name("ck_identity_active_attempts"), "(\"status\" <> CAST('ACTIVE' AS ENUM('ACTIVE', 'USED', 'LOCKED', 'EXPIRED', 'INVALIDATED')))\n OR (\"attempts\" < \"max_attempts\")", true), + Internal.createCheck(this, DSL.name("ck_identity_attempts"), "(\"max_attempts\" > 0)\n AND (\"attempts\" BETWEEN 0 AND \"max_attempts\")", true), + Internal.createCheck(this, DSL.name("ck_identity_binding_hash"), "UNKNOWN\n OR (\"device_binding_hash\" IS NULL)", true), + Internal.createCheck(this, DSL.name("ck_identity_code_hash"), "(\"verification_mode\" <> 'RANDOM_NUMERIC')\n OR (UNKNOWN\n AND (\"code_hash\" IS NOT NULL))", true), + Internal.createCheck(this, DSL.name("ck_identity_consumption"), "(\"status\" = CAST('USED' AS ENUM('ACTIVE', 'USED', 'LOCKED', 'EXPIRED', 'INVALIDATED'))) = (\"consumed_at\" IS NOT NULL)", true), + Internal.createCheck(this, DSL.name("ck_identity_consumption_time"), "(\"consumed_at\" IS NULL)\n OR ((\"consumed_at\" >= \"created_at\")\n AND (\"consumed_at\" < \"expires_at\"))", true), + Internal.createCheck(this, DSL.name("ck_identity_customer_id"), "\"user_id\" REGEXP '^cus_[A-Za-z0-9]{22}$'", true), + Internal.createCheck(this, DSL.name("ck_identity_expiry"), "\"expires_at\" > \"created_at\"", true), + Internal.createCheck(this, DSL.name("ck_identity_otp_id"), "\"id\" REGEXP '^otp_[A-Za-z0-9]{22}$'", true) + ); + } + + @Override + public IdentityOtp as(String alias) { + return new IdentityOtp(DSL.name(alias), this); + } + + @Override + public IdentityOtp as(Name alias) { + return new IdentityOtp(alias, this); + } + + @Override + public IdentityOtp as(Table alias) { + return new IdentityOtp(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public IdentityOtp rename(String name) { + return new IdentityOtp(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public IdentityOtp rename(Name name) { + return new IdentityOtp(name, null); + } + + /** + * Rename this table + */ + @Override + public IdentityOtp rename(Table name) { + return new IdentityOtp(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp where(Condition condition) { + return new IdentityOtp(getQualifiedName(), aliased() ? this : null, null, Internal.condition(this, condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtp where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtp where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtp where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtp where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp whereExists(TableLike select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtp whereNotExists(TableLike select) { + return where(DSL.notExists(select)); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtpScope.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtpScope.java new file mode 100644 index 0000000..2fcd266 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOtpScope.java @@ -0,0 +1,236 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Keys; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Payguard; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOtpScopeRecord; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.jooq.Check; +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableLike; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOtpScope extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of payguard.identity_otp_scope + */ + public static final IdentityOtpScope IDENTITY_OTP_SCOPE = new IdentityOtpScope(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return IdentityOtpScopeRecord.class; + } + + /** + * The column payguard.identity_otp_scope.user_id. + */ + public final TableField USER_ID = createField(DSL.name("user_id"), SQLDataType.VARCHAR(255).nullable(false), this, ""); + + /** + * The column payguard.identity_otp_scope.purpose. + */ + public final TableField PURPOSE = createField(DSL.name("purpose"), SQLDataType.VARCHAR(32).nullable(false), this, ""); + + private IdentityOtpScope(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private IdentityOtpScope(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased payguard.identity_otp_scope table + * reference + */ + public IdentityOtpScope(String alias) { + this(DSL.name(alias), IDENTITY_OTP_SCOPE); + } + + /** + * Create an aliased payguard.identity_otp_scope table + * reference + */ + public IdentityOtpScope(Name alias) { + this(alias, IDENTITY_OTP_SCOPE); + } + + /** + * Create a payguard.identity_otp_scope table reference + */ + public IdentityOtpScope() { + this(DSL.name("identity_otp_scope"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : Payguard.PAYGUARD; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.CONSTRAINT_9; + } + + @Override + public List> getChecks() { + return Arrays.asList( + Internal.createCheck(this, DSL.name("ck_identity_scope_customer_id"), "\"user_id\" REGEXP '^cus_[A-Za-z0-9]{22}$'", true) + ); + } + + @Override + public IdentityOtpScope as(String alias) { + return new IdentityOtpScope(DSL.name(alias), this); + } + + @Override + public IdentityOtpScope as(Name alias) { + return new IdentityOtpScope(alias, this); + } + + @Override + public IdentityOtpScope as(Table alias) { + return new IdentityOtpScope(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public IdentityOtpScope rename(String name) { + return new IdentityOtpScope(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public IdentityOtpScope rename(Name name) { + return new IdentityOtpScope(name, null); + } + + /** + * Rename this table + */ + @Override + public IdentityOtpScope rename(Table name) { + return new IdentityOtpScope(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope where(Condition condition) { + return new IdentityOtpScope(getQualifiedName(), aliased() ? this : null, null, Internal.condition(this, condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtpScope where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtpScope where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtpScope where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOtpScope where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope whereExists(TableLike select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOtpScope whereNotExists(TableLike select) { + return where(DSL.notExists(select)); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOutbox.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOutbox.java new file mode 100644 index 0000000..d89a90d --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/IdentityOutbox.java @@ -0,0 +1,280 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Indexes; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Keys; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Payguard; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOutboxRecord; + +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +import org.jooq.Check; +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Index; +import org.jooq.JSON; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableLike; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOutbox extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of payguard.identity_outbox + */ + public static final IdentityOutbox IDENTITY_OUTBOX = new IdentityOutbox(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return IdentityOutboxRecord.class; + } + + /** + * The column payguard.identity_outbox.event_id. + */ + public final TableField EVENT_ID = createField(DSL.name("event_id"), SQLDataType.UUID.nullable(false).defaultValue(DSL.field(DSL.raw("RANDOM_UUID()"), SQLDataType.UUID)), this, ""); + + /** + * The column payguard.identity_outbox.aggregate_id. + */ + public final TableField AGGREGATE_ID = createField(DSL.name("aggregate_id"), SQLDataType.VARCHAR(26).nullable(false), this, ""); + + /** + * The column payguard.identity_outbox.event_type. + */ + public final TableField EVENT_TYPE = createField(DSL.name("event_type"), SQLDataType.VARCHAR(128).nullable(false), this, ""); + + /** + * The column payguard.identity_outbox.payload. + */ + public final TableField PAYLOAD = createField(DSL.name("payload"), SQLDataType.JSON.nullable(false), this, ""); + + /** + * The column payguard.identity_outbox.occurred_at. + */ + public final TableField OCCURRED_AT = createField(DSL.name("occurred_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false), this, ""); + + /** + * The column payguard.identity_outbox.created_at. + */ + public final TableField CREATED_AT = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, ""); + + /** + * The column payguard.identity_outbox.published_at. + */ + public final TableField PUBLISHED_AT = createField(DSL.name("published_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, ""); + + /** + * The column payguard.identity_outbox.next_attempt_at. + */ + public final TableField NEXT_ATTEMPT_AT = createField(DSL.name("next_attempt_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, ""); + + /** + * The column payguard.identity_outbox.attempt_count. + */ + public final TableField ATTEMPT_COUNT = createField(DSL.name("attempt_count"), SQLDataType.INTEGER.nullable(false).defaultValue(DSL.field(DSL.raw("0"), SQLDataType.INTEGER)), this, ""); + + private IdentityOutbox(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private IdentityOutbox(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased payguard.identity_outbox table reference + */ + public IdentityOutbox(String alias) { + this(DSL.name(alias), IDENTITY_OUTBOX); + } + + /** + * Create an aliased payguard.identity_outbox table reference + */ + public IdentityOutbox(Name alias) { + this(alias, IDENTITY_OUTBOX); + } + + /** + * Create a payguard.identity_outbox table reference + */ + public IdentityOutbox() { + this(DSL.name("identity_outbox"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : Payguard.PAYGUARD; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.IX_IDENTITY_OUTBOX_PENDING); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.CONSTRAINT_D; + } + + @Override + public List> getChecks() { + return Arrays.asList( + Internal.createCheck(this, DSL.name("ck_identity_outbox_attempts"), "\"attempt_count\" >= 0", true), + Internal.createCheck(this, DSL.name("ck_identity_outbox_payload"), "UNKNOWN", true) + ); + } + + @Override + public IdentityOutbox as(String alias) { + return new IdentityOutbox(DSL.name(alias), this); + } + + @Override + public IdentityOutbox as(Name alias) { + return new IdentityOutbox(alias, this); + } + + @Override + public IdentityOutbox as(Table alias) { + return new IdentityOutbox(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public IdentityOutbox rename(String name) { + return new IdentityOutbox(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public IdentityOutbox rename(Name name) { + return new IdentityOutbox(name, null); + } + + /** + * Rename this table + */ + @Override + public IdentityOutbox rename(Table name) { + return new IdentityOutbox(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox where(Condition condition) { + return new IdentityOutbox(getQualifiedName(), aliased() ? this : null, null, Internal.condition(this, condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOutbox where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOutbox where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOutbox where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public IdentityOutbox where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox whereExists(TableLike select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public IdentityOutbox whereNotExists(TableLike select) { + return where(DSL.notExists(select)); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityAuditLogRecord.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityAuditLogRecord.java new file mode 100644 index 0000000..135daa9 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityAuditLogRecord.java @@ -0,0 +1,157 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityAuditLog; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityAuditLogRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for payguard.identity_audit_log.id. + */ + public void setId(UUID value) { + set(0, value); + } + + /** + * Getter for payguard.identity_audit_log.id. + */ + public UUID getId() { + return (UUID) get(0); + } + + /** + * Setter for payguard.identity_audit_log.otp_id. + */ + public void setOtpId(String value) { + set(1, value); + } + + /** + * Getter for payguard.identity_audit_log.otp_id. + */ + public String getOtpId() { + return (String) get(1); + } + + /** + * Setter for payguard.identity_audit_log.user_id. + */ + public void setUserId(String value) { + set(2, value); + } + + /** + * Getter for payguard.identity_audit_log.user_id. + */ + public String getUserId() { + return (String) get(2); + } + + /** + * Setter for payguard.identity_audit_log.action. + */ + public void setAction(String value) { + set(3, value); + } + + /** + * Getter for payguard.identity_audit_log.action. + */ + public String getAction() { + return (String) get(3); + } + + /** + * Setter for payguard.identity_audit_log.reason. + */ + public void setReason(String value) { + set(4, value); + } + + /** + * Getter for payguard.identity_audit_log.reason. + */ + public String getReason() { + return (String) get(4); + } + + /** + * Setter for payguard.identity_audit_log.source_ip. + */ + public void setSourceIp(String value) { + set(5, value); + } + + /** + * Getter for payguard.identity_audit_log.source_ip. + */ + public String getSourceIp() { + return (String) get(5); + } + + /** + * Setter for payguard.identity_audit_log.occurred_at. + */ + public void setOccurredAt(OffsetDateTime value) { + set(6, value); + } + + /** + * Getter for payguard.identity_audit_log.occurred_at. + */ + public OffsetDateTime getOccurredAt() { + return (OffsetDateTime) get(6); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached IdentityAuditLogRecord + */ + public IdentityAuditLogRecord() { + super(IdentityAuditLog.IDENTITY_AUDIT_LOG); + } + + /** + * Create a detached, initialised IdentityAuditLogRecord + */ + public IdentityAuditLogRecord(UUID id, String otpId, String userId, String action, String reason, String sourceIp, OffsetDateTime occurredAt) { + super(IdentityAuditLog.IDENTITY_AUDIT_LOG); + + setId(id); + setOtpId(otpId); + setUserId(userId); + setAction(action); + setReason(reason); + setSourceIp(sourceIp); + setOccurredAt(occurredAt); + resetTouchedOnNotNull(); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpRecord.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpRecord.java new file mode 100644 index 0000000..83f4011 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpRecord.java @@ -0,0 +1,262 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.enums.IdentityStatus; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtp; + +import java.time.OffsetDateTime; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOtpRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for payguard.identity_otp.id. + */ + public void setId(String value) { + set(0, value); + } + + /** + * Getter for payguard.identity_otp.id. + */ + public String getId() { + return (String) get(0); + } + + /** + * Setter for payguard.identity_otp.user_id. + */ + public void setUserId(String value) { + set(1, value); + } + + /** + * Getter for payguard.identity_otp.user_id. + */ + public String getUserId() { + return (String) get(1); + } + + /** + * Setter for payguard.identity_otp.purpose. + */ + public void setPurpose(String value) { + set(2, value); + } + + /** + * Getter for payguard.identity_otp.purpose. + */ + public String getPurpose() { + return (String) get(2); + } + + /** + * Setter for payguard.identity_otp.channel. + */ + public void setChannel(String value) { + set(3, value); + } + + /** + * Getter for payguard.identity_otp.channel. + */ + public String getChannel() { + return (String) get(3); + } + + /** + * Setter for payguard.identity_otp.verification_mode. + */ + public void setVerificationMode(String value) { + set(4, value); + } + + /** + * Getter for payguard.identity_otp.verification_mode. + */ + public String getVerificationMode() { + return (String) get(4); + } + + /** + * Setter for payguard.identity_otp.code_hash. + */ + public void setCodeHash(String value) { + set(5, value); + } + + /** + * Getter for payguard.identity_otp.code_hash. + */ + public String getCodeHash() { + return (String) get(5); + } + + /** + * Setter for payguard.identity_otp.device_binding_hash. + */ + public void setDeviceBindingHash(String value) { + set(6, value); + } + + /** + * Getter for payguard.identity_otp.device_binding_hash. + */ + public String getDeviceBindingHash() { + return (String) get(6); + } + + /** + * Setter for payguard.identity_otp.attempts. + */ + public void setAttempts(Integer value) { + set(7, value); + } + + /** + * Getter for payguard.identity_otp.attempts. + */ + public Integer getAttempts() { + return (Integer) get(7); + } + + /** + * Setter for payguard.identity_otp.max_attempts. + */ + public void setMaxAttempts(Integer value) { + set(8, value); + } + + /** + * Getter for payguard.identity_otp.max_attempts. + */ + public Integer getMaxAttempts() { + return (Integer) get(8); + } + + /** + * Setter for payguard.identity_otp.status. + */ + public void setStatus(IdentityStatus value) { + set(9, value); + } + + /** + * Getter for payguard.identity_otp.status. + */ + public IdentityStatus getStatus() { + return (IdentityStatus) get(9); + } + + /** + * Setter for payguard.identity_otp.created_at. + */ + public void setCreatedAt(OffsetDateTime value) { + set(10, value); + } + + /** + * Getter for payguard.identity_otp.created_at. + */ + public OffsetDateTime getCreatedAt() { + return (OffsetDateTime) get(10); + } + + /** + * Setter for payguard.identity_otp.expires_at. + */ + public void setExpiresAt(OffsetDateTime value) { + set(11, value); + } + + /** + * Getter for payguard.identity_otp.expires_at. + */ + public OffsetDateTime getExpiresAt() { + return (OffsetDateTime) get(11); + } + + /** + * Setter for payguard.identity_otp.consumed_at. + */ + public void setConsumedAt(OffsetDateTime value) { + set(12, value); + } + + /** + * Getter for payguard.identity_otp.consumed_at. + */ + public OffsetDateTime getConsumedAt() { + return (OffsetDateTime) get(12); + } + + /** + * Setter for payguard.identity_otp.retention_until. + */ + public void setRetentionUntil(OffsetDateTime value) { + set(13, value); + } + + /** + * Getter for payguard.identity_otp.retention_until. + */ + public OffsetDateTime getRetentionUntil() { + return (OffsetDateTime) get(13); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached IdentityOtpRecord + */ + public IdentityOtpRecord() { + super(IdentityOtp.IDENTITY_OTP); + } + + /** + * Create a detached, initialised IdentityOtpRecord + */ + public IdentityOtpRecord(String id, String userId, String purpose, String channel, String verificationMode, String codeHash, String deviceBindingHash, Integer attempts, Integer maxAttempts, IdentityStatus status, OffsetDateTime createdAt, OffsetDateTime expiresAt, OffsetDateTime consumedAt, OffsetDateTime retentionUntil) { + super(IdentityOtp.IDENTITY_OTP); + + setId(id); + setUserId(userId); + setPurpose(purpose); + setChannel(channel); + setVerificationMode(verificationMode); + setCodeHash(codeHash); + setDeviceBindingHash(deviceBindingHash); + setAttempts(attempts); + setMaxAttempts(maxAttempts); + setStatus(status); + setCreatedAt(createdAt); + setExpiresAt(expiresAt); + setConsumedAt(consumedAt); + setRetentionUntil(retentionUntil); + resetTouchedOnNotNull(); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpScopeRecord.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpScopeRecord.java new file mode 100644 index 0000000..15d5eb0 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOtpScopeRecord.java @@ -0,0 +1,79 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOtpScope; + +import org.jooq.Record2; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOtpScopeRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for payguard.identity_otp_scope.user_id. + */ + public void setUserId(String value) { + set(0, value); + } + + /** + * Getter for payguard.identity_otp_scope.user_id. + */ + public String getUserId() { + return (String) get(0); + } + + /** + * Setter for payguard.identity_otp_scope.purpose. + */ + public void setPurpose(String value) { + set(1, value); + } + + /** + * Getter for payguard.identity_otp_scope.purpose. + */ + public String getPurpose() { + return (String) get(1); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record2 key() { + return (Record2) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached IdentityOtpScopeRecord + */ + public IdentityOtpScopeRecord() { + super(IdentityOtpScope.IDENTITY_OTP_SCOPE); + } + + /** + * Create a detached, initialised IdentityOtpScopeRecord + */ + public IdentityOtpScopeRecord(String userId, String purpose) { + super(IdentityOtpScope.IDENTITY_OTP_SCOPE); + + setUserId(userId); + setPurpose(purpose); + resetTouchedOnNotNull(); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOutboxRecord.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOutboxRecord.java new file mode 100644 index 0000000..568be59 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/infrastructure/adapter/out/persistence/jooq/generated/tables/records/IdentityOutboxRecord.java @@ -0,0 +1,188 @@ +/* + * This file is generated by jOOQ. + */ +package dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records; + + +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.IdentityOutbox; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.jooq.JSON; +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class IdentityOutboxRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for payguard.identity_outbox.event_id. + */ + public void setEventId(UUID value) { + set(0, value); + } + + /** + * Getter for payguard.identity_outbox.event_id. + */ + public UUID getEventId() { + return (UUID) get(0); + } + + /** + * Setter for payguard.identity_outbox.aggregate_id. + */ + public void setAggregateId(String value) { + set(1, value); + } + + /** + * Getter for payguard.identity_outbox.aggregate_id. + */ + public String getAggregateId() { + return (String) get(1); + } + + /** + * Setter for payguard.identity_outbox.event_type. + */ + public void setEventType(String value) { + set(2, value); + } + + /** + * Getter for payguard.identity_outbox.event_type. + */ + public String getEventType() { + return (String) get(2); + } + + /** + * Setter for payguard.identity_outbox.payload. + */ + public void setPayload(JSON value) { + set(3, value); + } + + /** + * Getter for payguard.identity_outbox.payload. + */ + public JSON getPayload() { + return (JSON) get(3); + } + + /** + * Setter for payguard.identity_outbox.occurred_at. + */ + public void setOccurredAt(OffsetDateTime value) { + set(4, value); + } + + /** + * Getter for payguard.identity_outbox.occurred_at. + */ + public OffsetDateTime getOccurredAt() { + return (OffsetDateTime) get(4); + } + + /** + * Setter for payguard.identity_outbox.created_at. + */ + public void setCreatedAt(OffsetDateTime value) { + set(5, value); + } + + /** + * Getter for payguard.identity_outbox.created_at. + */ + public OffsetDateTime getCreatedAt() { + return (OffsetDateTime) get(5); + } + + /** + * Setter for payguard.identity_outbox.published_at. + */ + public void setPublishedAt(OffsetDateTime value) { + set(6, value); + } + + /** + * Getter for payguard.identity_outbox.published_at. + */ + public OffsetDateTime getPublishedAt() { + return (OffsetDateTime) get(6); + } + + /** + * Setter for payguard.identity_outbox.next_attempt_at. + */ + public void setNextAttemptAt(OffsetDateTime value) { + set(7, value); + } + + /** + * Getter for payguard.identity_outbox.next_attempt_at. + */ + public OffsetDateTime getNextAttemptAt() { + return (OffsetDateTime) get(7); + } + + /** + * Setter for payguard.identity_outbox.attempt_count. + */ + public void setAttemptCount(Integer value) { + set(8, value); + } + + /** + * Getter for payguard.identity_outbox.attempt_count. + */ + public Integer getAttemptCount() { + return (Integer) get(8); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached IdentityOutboxRecord + */ + public IdentityOutboxRecord() { + super(IdentityOutbox.IDENTITY_OUTBOX); + } + + /** + * Create a detached, initialised IdentityOutboxRecord + */ + public IdentityOutboxRecord(UUID eventId, String aggregateId, String eventType, JSON payload, OffsetDateTime occurredAt, OffsetDateTime createdAt, OffsetDateTime publishedAt, OffsetDateTime nextAttemptAt, Integer attemptCount) { + super(IdentityOutbox.IDENTITY_OUTBOX); + + setEventId(eventId); + setAggregateId(aggregateId); + setEventType(eventType); + setPayload(payload); + setOccurredAt(occurredAt); + setCreatedAt(createdAt); + setPublishedAt(publishedAt); + setNextAttemptAt(nextAttemptAt); + setAttemptCount(attemptCount); + resetTouchedOnNotNull(); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommand.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommand.java new file mode 100644 index 0000000..51e1132 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommand.java @@ -0,0 +1,53 @@ +package dev.amg.payguard.identity.verification.application.command.issue_otp; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.util.Objects; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** Command requesting issuance of a new OTP challenge. */ +@NullMarked +public record IssueOtpCommand( + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + String destinationRef, + @Nullable String deviceFingerprint, + String sourceIp) { + public IssueOtpCommand { + Objects.requireNonNull(customerId, "CustomerId cannot be null"); + Objects.requireNonNull(purpose, "Purpose cannot be null"); + Objects.requireNonNull(channel, "Channel cannot be null"); + Objects.requireNonNull(destinationRef, "Destination Reference cannot be null"); + Objects.requireNonNull(sourceIp, "Source IP cannot be null"); + + if (destinationRef.isBlank() || sourceIp.isBlank()) { + throw new UnsupportedOperationException(); + } + if (deviceFingerprint != null && deviceFingerprint.isBlank()) { + throw new UnsupportedOperationException(); + } + } + + public static IssueOtpCommand of( + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + String destinationRef, + String deviceFingerprint, + String sourceIp) { + return new IssueOtpCommand( + customerId, purpose, channel, destinationRef, deviceFingerprint, sourceIp); + } + + @Override + public String toString() { + return "IssueOtpCommand[purpose=" + + purpose + + ", channel=" + + channel + + ", destinationRef=[REDACTED], deviceFingerprint=[REDACTED], sourceIp=[REDACTED]]"; + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommandResponse.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommandResponse.java new file mode 100644 index 0000000..0d309ef --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpCommandResponse.java @@ -0,0 +1,33 @@ +package dev.amg.payguard.identity.verification.application.command.issue_otp; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; +import java.util.Objects; + +/** Metadata returned after an OTP challenge has been issued. */ +public record IssueOtpCommandResponse( + OtpId otpId, + OtpPurpose purpose, + OtpChannel channel, + String destinationRef, + String sourceIp, + Instant expiresAt) { + public IssueOtpCommandResponse { + Objects.requireNonNull(otpId, "otpId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(channel, "channel must not be null"); + Objects.requireNonNull(expiresAt, "expiresAt must not be null"); + } + + public static IssueOtpCommandResponse from(OtpId id, IssueOtpCommand command, Instant expiresAt) { + return new IssueOtpCommandResponse( + id, + command.purpose(), + command.channel(), + command.destinationRef(), + command.sourceIp(), + expiresAt); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpHandler.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpHandler.java new file mode 100644 index 0000000..35f9dde --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/IssueOtpHandler.java @@ -0,0 +1,175 @@ +package dev.amg.payguard.identity.verification.application.command.issue_otp; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.command.issue_otp.exception.InvalidOtpIssuanceConfigurationException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpRateLimitExceededException; +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditAction; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitOperation; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPort; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles the issuance of OTP challenges. + * + *

Coordinates rate limiting, code generation, challenge creation, persistence, auditing, and + * delivery. + * + *

Business invariants remain within the OTP aggregate. + */ +public class IssueOtpHandler { + + private final OtpRepository repository; + private final OtpCodeGenerator generator; + private final OtpCodeHasher hasher; + private final RateLimitPort quota; + private final OtpDelivery delivery; + private final VerificationAudit audit; + private final Supplier ids; + private final Clock clock; + private final Duration ttl; + private final Duration auditRetention; + private final int maxAttempts; + + public IssueOtpHandler( + OtpRepository repository, + OtpCodeGenerator generator, + OtpCodeHasher hasher, + RateLimitPort quota, + OtpDelivery delivery, + VerificationAudit audit, + Supplier ids, + Clock clock, + Duration ttl, + Duration auditRetention, + int maxAttempts) { + + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.generator = Objects.requireNonNull(generator, "generator must not be null"); + this.hasher = Objects.requireNonNull(hasher, "hasher must not be null"); + this.quota = Objects.requireNonNull(quota, "quota must not be null"); + this.delivery = Objects.requireNonNull(delivery, "delivery must not be null"); + this.audit = Objects.requireNonNull(audit, "audit must not be null"); + this.ids = Objects.requireNonNull(ids, "ids must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + validate(ttl, auditRetention, maxAttempts); + this.ttl = Objects.requireNonNull(ttl, "ttl must not be null"); + this.auditRetention = Objects.requireNonNull(auditRetention, "auditRetention must not be null"); + + this.maxAttempts = maxAttempts; + } + + /** + * Issues a new OTP challenge for the requested customer and purpose. + * + * @param command the OTP issuance request + * @return metadata describing the issued challenge + */ + @Transactional + public IssueOtpCommandResponse handle(IssueOtpCommand command) { + Objects.requireNonNull(command, "command must not be null"); + + boolean allowed = + quota + .acquire( + new RateLimitRequest( + RateLimitOperation.ISSUE, + command.customerId().getValue(), + null, + command.sourceIp(), + command.purpose())) + .permitted(); + + if (!allowed) { + throw new OtpRateLimitExceededException(); + } + + Instant now = clock.instant().truncatedTo(ChronoUnit.MICROS); + + OtpId id = Objects.requireNonNull(ids.get(), "Generated OTP ID must not be null"); + + String code = + Objects.requireNonNull(generator.generate(), "Generated OTP code must not be null"); + + if (code.isBlank()) { + throw new IllegalStateException("Generated OTP code must not be blank"); + } + + String codeHash = hasher.hash(code); + + String deviceBindingHash = + command.deviceFingerprint() == null ? null : hasher.hash(command.deviceFingerprint()); + + OtpSnapshot snapshot = + new OtpSnapshot( + command.purpose(), + command.channel(), + OtpVerificationMode.RANDOM_NUMERIC, + codeHash, + deviceBindingHash, + maxAttempts, + now, + now.plus(ttl), + now.plus(auditRetention), + 0, + OtpStatus.ACTIVE, + null); + + OTP otp = OTP.issue(id, command.customerId(), snapshot); + + repository.replaceActive(otp); + + audit.recordAudit( + new VerificationAuditEntry( + id, + command.customerId(), + VerificationAuditAction.ISSUED, + "challenge-issued", + command.sourceIp(), + now)); + + delivery.submit( + new OtpDeliveryRequest( + id, + command.customerId(), + command.purpose(), + command.channel(), + command.destinationRef(), + code)); + + return IssueOtpCommandResponse.from(otp.getId(), command, otp.getSnapshot().expiresAt()); + } + + private static void validate(Duration ttl, Duration auditRetention, int maxAttempts) { + + if (ttl.isZero() || ttl.isNegative()) { + throw new InvalidOtpIssuanceConfigurationException("OTP TTL must be positive"); + } + + if (auditRetention.isZero() || auditRetention.isNegative()) { + throw new InvalidOtpIssuanceConfigurationException("Audit retention must be positive"); + } + + if (maxAttempts <= 0) { + throw new InvalidOtpIssuanceConfigurationException( + "Maximum verification attempts must be at least 1"); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/InvalidOtpIssuanceConfigurationException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/InvalidOtpIssuanceConfigurationException.java new file mode 100644 index 0000000..abafefc --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/InvalidOtpIssuanceConfigurationException.java @@ -0,0 +1,10 @@ +package dev.amg.payguard.identity.verification.application.command.issue_otp.exception; + +/** Raised when OTP issuance configuration violates required constraints. */ +public final class InvalidOtpIssuanceConfigurationException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public InvalidOtpIssuanceConfigurationException(String reason) { + super("Invalid OTP issuance configuration: " + reason); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/UnsupportedOtpChannelException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/UnsupportedOtpChannelException.java new file mode 100644 index 0000000..b04bab0 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/issue_otp/exception/UnsupportedOtpChannelException.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity.verification.application.command.issue_otp.exception; + +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; + +/** Raised when OTP issuance is requested through an unsupported channel. */ +public final class UnsupportedOtpChannelException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public UnsupportedOtpChannelException(OtpChannel channel) { + super("OTP issuance is not supported for channel: " + channel); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationCommand.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationCommand.java new file mode 100644 index 0000000..b56bb7b --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationCommand.java @@ -0,0 +1,71 @@ +package dev.amg.payguard.identity.verification.application.command.otp_verification; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.InvalidOtpVerificationCommandException; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public record OtpVerificationCommand( + OtpId otpId, + CustomerId customerId, + OtpPurpose purpose, + String code, + String deviceFingerprint, + String sourceIp) { + + public OtpVerificationCommand { + requirePresent(otpId, "otpId"); + requirePresent(customerId, "customerId"); + requirePresent(purpose, "purpose"); + + requireNotBlank(code, "code"); + requireNotBlank(deviceFingerprint, "deviceFingerprint"); + requireNotBlank(sourceIp, "sourceIp"); + + if (code.isBlank()) { + throw new InvalidOtpVerificationCommandException("OTP code must not be blank"); + } + + if (deviceFingerprint.isBlank()) { + throw new InvalidOtpVerificationCommandException("Device fingerprint must not be blank"); + } + + if (sourceIp.isBlank()) { + throw new InvalidOtpVerificationCommandException("Source IP must not be blank"); + } + } + + public static OtpVerificationCommand of( + OtpId challengeId, + CustomerId customerId, + OtpPurpose purpose, + String code, + String deviceFingerprint, + String sourceIp) { + + return new OtpVerificationCommand( + challengeId, customerId, purpose, code, deviceFingerprint, sourceIp); + } + + @Override + public String toString() { + return "OtpVerificationCommand[otpId=" + + otpId + + ", code=[REDACTED], deviceFingerprint=[REDACTED], sourceIp=[REDACTED]]"; + } + + private static void requireNotBlank(@Nullable String value, String field) { + if (value == null || value.isBlank()) { + throw new InvalidOtpVerificationCommandException(field + " must not be null or blank"); + } + } + + private static void requirePresent(@Nullable Object value, String field) { + if (value == null) { + throw new InvalidOtpVerificationCommandException(field + " must not be null"); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationHandler.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationHandler.java new file mode 100644 index 0000000..e979e46 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationHandler.java @@ -0,0 +1,132 @@ +package dev.amg.payguard.identity.verification.application.command.otp_verification; + +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.InvalidOtpVerificationCommandException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpChallengeNotFoundException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpRateLimitExceededException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpVerificationException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.UnsupportedOtpVerificationModeException; +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditAction; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitOperation; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPort; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Function; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Handles OTP verification requests. + * + *

Coordinates rate limiting, locked verification, and auditing. The OTP aggregate owns + * verification rules and state transitions. + */ +@Service +public class OtpVerificationHandler { + + private final OtpRepository repository; + private final OtpCodeHasher hasher; + private final RateLimitPort quota; + private final VerificationAudit audit; + private final Clock clock; + + public OtpVerificationHandler( + OtpRepository repository, + OtpCodeHasher hasher, + RateLimitPort quota, + VerificationAudit audit, + Clock clock) { + + this.repository = Objects.requireNonNull(repository, "repository must not be null"); + this.hasher = Objects.requireNonNull(hasher, "hasher must not be null"); + this.quota = Objects.requireNonNull(quota, "quota must not be null"); + this.audit = Objects.requireNonNull(audit, "audit must not be null"); + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + } + + /** + * Verifies an existing OTP challenge. + * + *

Verification runs against the latest persisted state while the challenge is locked. Rejected + * results are converted to exceptions only after the repository transaction completes. + * + * @param command the verification request + * @return the successful verification response + * @throws InvalidOtpVerificationCommandException if the request is invalid + * @throws OtpChallengeNotFoundException if the challenge is unavailable + * @throws OtpRateLimitExceededException if the verification quota is exhausted + * @throws OtpVerificationException if verification is rejected + */ + @Transactional(noRollbackFor = OtpVerificationException.class) + public OtpVerificationResponse handle(OtpVerificationCommand command) { + + boolean allowed = + quota + .acquire( + new RateLimitRequest( + RateLimitOperation.VERIFY, + command.customerId().getValue(), + null, + command.sourceIp(), + command.purpose())) + .permitted(); + + if (!allowed) { + throw new OtpRateLimitExceededException(); + } + + final Function verificationOutcomeFunction = + otp -> { + if (!otp.getCustomerId().equals(command.customerId())) { + throw new OtpChallengeNotFoundException(command.otpId()); + } + + if (otp.getSnapshot().verificationMode() != OtpVerificationMode.RANDOM_NUMERIC) { + throw new UnsupportedOtpVerificationModeException(otp.getSnapshot().verificationMode()); + } + + Instant now = clock.instant().truncatedTo(ChronoUnit.MICROS); + + final var otpCommand = + new VerifyOtpCommand( + command.purpose(), command.code(), command.deviceFingerprint(), now); + + OtpVerificationResult result = otp.verify(otpCommand, hasher); + + return new VerificationOutcome(result, now); + }; + + final VerificationOutcome outcome = + repository + .withLockedChallenge(command.otpId(), verificationOutcomeFunction) + .orElseThrow(() -> new OtpChallengeNotFoundException(command.otpId())); + + audit.recordAudit( + new VerificationAuditEntry( + command.otpId(), + command.customerId(), + VerificationAuditAction.VERIFY, + outcome.result().name().toLowerCase(Locale.ROOT), + command.sourceIp(), + outcome.at())); + + if (outcome.result() != OtpVerificationResult.VERIFIED) { + throw new OtpVerificationException(outcome.result()); + } + + return OtpVerificationResponse.from(command.otpId(), command.purpose(), outcome.at()); + } + + private record VerificationOutcome(OtpVerificationResult result, Instant at) {} +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationResponse.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationResponse.java new file mode 100644 index 0000000..6076a50 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/OtpVerificationResponse.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.application.command.otp_verification; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; +import java.util.Objects; + +/** + * Successful completion of an OTP challenge. + * + *

Does not represent authorization to perform a financial operation. + */ +public record OtpVerificationResponse(OtpId challengeId, OtpPurpose purpose, Instant verifiedAt) { + + public OtpVerificationResponse { + Objects.requireNonNull(challengeId, "otpId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(verifiedAt, "verifiedAt must not be null"); + } + + public static OtpVerificationResponse from( + OtpId challengeId, OtpPurpose purpose, Instant verifiedAt) { + + return new OtpVerificationResponse(challengeId, purpose, verifiedAt); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/InvalidOtpVerificationCommandException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/InvalidOtpVerificationCommandException.java new file mode 100644 index 0000000..7a5f054 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/InvalidOtpVerificationCommandException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.application.command.otp_verification.exception; + +import java.io.Serial; + +/** Raised when an OTP verification command has invalid input. */ +public final class InvalidOtpVerificationCommandException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + public InvalidOtpVerificationCommandException(String reason) { + super("Invalid OTP verification request: " + reason); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpChallengeNotFoundException.java similarity index 59% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpChallengeNotFoundException.java index b853009..b47ff51 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpChallengeNotFoundException.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpChallengeNotFoundException.java @@ -1,10 +1,10 @@ -package dev.amg.payguard.identity.verification.application; +package dev.amg.payguard.identity.verification.application.command.otp_verification.exception; import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import java.io.Serial; public class OtpChallengeNotFoundException extends RuntimeException { - - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public OtpChallengeNotFoundException(OtpId challengeId) { super("OTP challenge not found: " + challengeId); diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpRateLimitExceededException.java similarity index 71% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpRateLimitExceededException.java index 801d3b0..b6d82c5 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpRateLimitExceededException.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpRateLimitExceededException.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.application; +package dev.amg.payguard.identity.verification.application.command.otp_verification.exception; import java.io.Serial; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpVerificationException.java similarity index 73% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpVerificationException.java index c1ba04d..17d7a8c 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpVerificationException.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/OtpVerificationException.java @@ -1,10 +1,10 @@ -package dev.amg.payguard.identity.verification.application; +package dev.amg.payguard.identity.verification.application.command.otp_verification.exception; import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import java.io.Serial; public class OtpVerificationException extends RuntimeException { - - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; private final OtpVerificationResult verificationResult; public OtpVerificationException(OtpVerificationResult result) { diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/UnsupportedOtpVerificationModeException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/UnsupportedOtpVerificationModeException.java new file mode 100644 index 0000000..ff7e761 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/command/otp_verification/exception/UnsupportedOtpVerificationModeException.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.identity.verification.application.command.otp_verification.exception; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import java.io.Serial; + +/** Raised when an OTP uses a verification mode unsupported by this use case. */ +public final class UnsupportedOtpVerificationModeException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + public UnsupportedOtpVerificationModeException(OtpVerificationMode mode) { + super("Unsupported OTP verification mode: " + mode); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/AuthenticatorSecretPort.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDelivery.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java similarity index 81% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java index 7ed1958..25d1728 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/OtpDeliveryRequest.java @@ -4,10 +4,12 @@ import dev.amg.payguard.identity.customer.domain.value_object.OtpId; import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import org.jspecify.annotations.NullMarked; /** destinationRef is the caller's delivery reference; provider resolution is not implemented. */ +@NullMarked public record OtpDeliveryRequest( - OtpId challengeId, + OtpId otpId, CustomerId customerId, OtpPurpose purpose, OtpChannel channel, @@ -15,6 +17,6 @@ public record OtpDeliveryRequest( String code) { @Override public String toString() { - return "OtpDeliveryRequest[challengeId=" + challengeId + ", code=[REDACTED]]"; + return "OtpDeliveryRequest[otpId=" + otpId + ", code=[REDACTED]]"; } } diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java similarity index 65% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java index cff3fac..57d9290 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAudit.java @@ -1,7 +1,10 @@ package dev.amg.payguard.identity.verification.application.port; +import org.springframework.stereotype.Service; + +@Service @FunctionalInterface public interface VerificationAudit { /** Persists synchronously; a persistence failure propagates to the caller. */ - void record(VerificationAuditEntry entry); + void recordAudit(VerificationAuditEntry entry); } diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditAction.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditAction.java new file mode 100644 index 0000000..f229cb4 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditAction.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.application.port; + +public enum VerificationAuditAction { + ISSUED, + VERIFY +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java similarity index 84% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java index 78f0aed..b6af4ac 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/VerificationAuditEntry.java @@ -11,12 +11,7 @@ public record VerificationAuditEntry( OtpId challengeId, CustomerId customerId, - Action action, + VerificationAuditAction verificationAuditAction, String reasonCode, String sourceIp, - Instant occurredAt) { - public enum Action { - ISSUED, - VERIFY - } -} + Instant occurredAt) {} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitDecision.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitDecision.java new file mode 100644 index 0000000..b4f2743 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitDecision.java @@ -0,0 +1,57 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +import java.time.Duration; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Represents the outcome of an OTP rate-limit admission check. + * + * @param permitted whether the request is permitted + * @param retryAfter duration before retrying, or null if permitted + * @param rejectedByPolicy rejecting policy name, or null if permitted + */ +public record RateLimitDecision( + boolean permitted, @Nullable Duration retryAfter, @Nullable String rejectedByPolicy) { + + public RateLimitDecision { + if (permitted) { + if (retryAfter != null || rejectedByPolicy != null) { + throw new IllegalArgumentException( + "Permitted decisions must not contain rejection metadata"); + } + } else { + Objects.requireNonNull(retryAfter, "retryAfter"); + Objects.requireNonNull(rejectedByPolicy, "rejectedByPolicy"); + + // Only happens if `permitted` is NULL. + if (retryAfter.isZero() || retryAfter.isNegative()) { + throw new IllegalArgumentException("Retry delay must be positive"); + } + + if (rejectedByPolicy.isBlank()) { + throw new IllegalArgumentException("Rejected policy name must not be blank"); + } + } + } + + /** + * Creates a decision that permits the request. + * + * @return a permitted rate-limit decision + */ + public static RateLimitDecision allowed() { + return new RateLimitDecision(true, null, null); + } + + /** + * Creates a decision that rejects the request. + * + * @param retryAfter positive duration before retrying + * @param rejectedByPolicy non-blank rejecting policy name + * @return a rejected rate-limit decision + */ + public static RateLimitDecision rejected(Duration retryAfter, String rejectedByPolicy) { + return new RateLimitDecision(false, retryAfter, rejectedByPolicy); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitOperation.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitOperation.java new file mode 100644 index 0000000..c27e6b6 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitOperation.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +/** Identifies OTP operations subject to rate limiting. */ +public enum RateLimitOperation { + ISSUE, + VERIFY, + RESEND +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPolicy.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPolicy.java new file mode 100644 index 0000000..dc24184 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPolicy.java @@ -0,0 +1,62 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +import java.time.Duration; +import org.jspecify.annotations.NonNull; + +/** + * Defines an immutable rate-limiting policy for the Token Bucket algorithm. + * + *

A policy specifies the maximum number of tokens a bucket can hold and the rate at which tokens + * are replenished. + * + *

All instances are guaranteed to satisfy the following invariants: + * + *

    + *
  • The policy name is neither null nor blank. + *
  • The bucket capacity is strictly positive. + *
  • The refill token count is strictly positive. + *
  • The refill period is strictly positive. + *
  • The refill period is representable in milliseconds. + *
+ * + *

This record is independent of Redis and contains no mutable Token Bucket state. + * + * @param name unique identifier of the rate-limiting policy + * @param capacity maximum number of tokens the bucket can hold + * @param refillTokens number of tokens replenished per refill period + * @param refillPeriod duration over which refillTokens are replenished + */ +public record RateLimitPolicy( + @NonNull String name, long capacity, long refillTokens, @NonNull Duration refillPeriod) { + + /** + * Validates the rate-limiting policy invariants. + * + * @throws IllegalArgumentException if any policy parameter is invalid + */ + public RateLimitPolicy { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Rate limit policy name must not be null or blank"); + } + + if (capacity <= 0) { + throw new IllegalArgumentException("Rate limit capacity must be greater than zero"); + } + + if (refillTokens <= 0) { + throw new IllegalArgumentException("Refill tokens must be greater than zero"); + } + + if (refillPeriod == null || refillPeriod.isZero() || refillPeriod.isNegative()) { + throw new IllegalArgumentException("Refill period must be greater than zero"); + } + + try { + if (refillPeriod.toMillis() <= 0) { + throw new IllegalArgumentException("Refill period must be at least one millisecond"); + } + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("Refill period exceeds the supported range", exception); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPort.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPort.java new file mode 100644 index 0000000..37119e7 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitPort.java @@ -0,0 +1,36 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +/** + * Defines the application contract for distributed rate-limit admission. + * + *

Implementations evaluate the rate-limiting policies applicable to an OTP operation and return + * an admission decision. + * + *

Each applicable policy represents a separate rate-limit dimension, such as a global limit, + * customer limit, or IP address limit. + * + *

A request is permitted only when all applicable policies allow it. + * + *

Implementations must enforce their documented concurrency guarantees. Infrastructure failures + * must not be represented as permitted decisions. + */ +@FunctionalInterface +public interface RateLimitPort { + + /** + * Evaluates whether an OTP operation may proceed. + * + *

Rate-limit state must be shared across service instances. + * + *

A permitted decision indicates that the relevant admission tokens have been consumed + * successfully. + * + *

A rejected decision indicates that at least one applicable policy does not have sufficient + * available tokens. + * + * @param request validated OTP operation and identity context + * @return the admission decision + * @throws RateLimitUnavailableException if admission cannot be evaluated reliably + */ + RateLimitDecision acquire(RateLimitRequest request) throws RateLimitUnavailableException; +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitRequest.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitRequest.java new file mode 100644 index 0000000..d58edf6 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitRequest.java @@ -0,0 +1,53 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Represents an OTP request subject to rate limiting. + * + *

Identity values must be resolved from trusted application context. Client-supplied identifiers + * must not be treated as authenticated identities. + * + * @param operation the OTP operation being performed + * @param customerId authenticated customer identifier, if available + * @param recipientFingerprint privacy-preserving recipient identifier, if available + * @param ipAddress trusted client IP address + * @param purpose OTP purpose used to scope customer and recipient limits + */ +public record RateLimitRequest( + RateLimitOperation operation, + @Nullable String customerId, + @Nullable String recipientFingerprint, + String ipAddress, + OtpPurpose purpose) { + + /** + * Validates the request's required attributes. + * + * @throws NullPointerException if a required attribute is null + * @throws IllegalArgumentException if an identifier is blank + */ + public RateLimitRequest { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(ipAddress, "ipAddress"); + Objects.requireNonNull(purpose, "purpose"); + + if (ipAddress.isBlank()) { + throw new IllegalArgumentException("IP address must not be blank"); + } + + if (customerId != null && customerId.isBlank()) { + throw new IllegalArgumentException("Customer ID must not be blank when provided"); + } + + if (recipientFingerprint != null && recipientFingerprint.isBlank()) { + throw new IllegalArgumentException("Recipient fingerprint must not be blank when provided"); + } + + if (customerId == null && recipientFingerprint == null) { + throw new IllegalArgumentException("Either customer ID or recipient fingerprint is required"); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitUnavailableException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitUnavailableException.java new file mode 100644 index 0000000..446dcf6 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/application/port/rate_limit/RateLimitUnavailableException.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.application.port.rate_limit; + +/** + * Indicates that the rate-limiting infrastructure could not reliably determine whether an operation + * should be permitted. + * + *

This exception does not represent a rate-limit rejection. Callers must apply the configured + * failure policy rather than automatically permitting the operation. + */ +public final class RateLimitUnavailableException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public RateLimitUnavailableException(String message) { + super(message); + } + + /** + * Creates an infrastructure failure with its underlying cause. + * + * @param message description of the failure + * @param cause underlying infrastructure exception + */ + public RateLimitUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java new file mode 100644 index 0000000..99299e4 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java @@ -0,0 +1,65 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public sealed interface OtpChannel permits OtpChannel.Sms, OtpChannel.Email, OtpChannel.Push { + OtpChannel SMS = new Sms(); + OtpChannel EMAIL = new Email(); + OtpChannel PUSH = new Push(); + + @JsonValue + String name(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + static OtpChannel valueOf(String name) { + return switch (name) { + case "SMS" -> SMS; + case "EMAIL" -> EMAIL; + case "PUSH" -> PUSH; + default -> throw new IllegalArgumentException("Unknown OtpChannel: " + name); + }; + } + + final class Sms implements OtpChannel { + private Sms() {} + + @Override + public String name() { + return "SMS"; + } + + @Override + public String toString() { + return name(); + } + } + + final class Email implements OtpChannel { + private Email() {} + + @Override + public String name() { + return "EMAIL"; + } + + @Override + public String toString() { + return name(); + } + } + + final class Push implements OtpChannel { + private Push() {} + + @Override + public String name() { + return "PUSH"; + } + + @Override + public String toString() { + return name(); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java new file mode 100644 index 0000000..97c8b63 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java @@ -0,0 +1,119 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public sealed interface OtpPurpose + permits OtpPurpose.Login, + OtpPurpose.TransferConfirm, + OtpPurpose.LoanDisburse, + OtpPurpose.CollateralRelease, + OtpPurpose.CardIssuance, + OtpPurpose.ProfileChange { + OtpPurpose LOGIN = new Login(); + OtpPurpose TRANSFER_CONFIRM = new TransferConfirm(); + OtpPurpose LOAN_DISBURSE = new LoanDisburse(); + OtpPurpose COLLATERAL_RELEASE = new CollateralRelease(); + OtpPurpose CARD_ISSUANCE = new CardIssuance(); + OtpPurpose PROFILE_CHANGE = new ProfileChange(); + + @JsonValue + String name(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + static OtpPurpose valueOf(String name) { + return switch (name) { + case "LOGIN" -> LOGIN; + case "TRANSFER_CONFIRM" -> TRANSFER_CONFIRM; + case "LOAN_DISBURSE" -> LOAN_DISBURSE; + case "COLLATERAL_RELEASE" -> COLLATERAL_RELEASE; + case "CARD_ISSUANCE" -> CARD_ISSUANCE; + case "PROFILE_CHANGE" -> PROFILE_CHANGE; + default -> throw new IllegalArgumentException("Unknown OtpPurpose: " + name); + }; + } + + final class Login implements OtpPurpose { + private Login() {} + + @Override + public String name() { + return "LOGIN"; + } + + @Override + public String toString() { + return name(); + } + } + + final class TransferConfirm implements OtpPurpose { + private TransferConfirm() {} + + @Override + public String name() { + return "TRANSFER_CONFIRM"; + } + + @Override + public String toString() { + return name(); + } + } + + final class LoanDisburse implements OtpPurpose { + private LoanDisburse() {} + + @Override + public String name() { + return "LOAN_DISBURSE"; + } + + @Override + public String toString() { + return name(); + } + } + + final class CollateralRelease implements OtpPurpose { + private CollateralRelease() {} + + @Override + public String name() { + return "COLLATERAL_RELEASE"; + } + + @Override + public String toString() { + return name(); + } + } + + final class CardIssuance implements OtpPurpose { + private CardIssuance() {} + + @Override + public String name() { + return "CARD_ISSUANCE"; + } + + @Override + public String toString() { + return name(); + } + } + + final class ProfileChange implements OtpPurpose { + private ProfileChange() {} + + @Override + public String name() { + return "PROFILE_CHANGE"; + } + + @Override + public String toString() { + return name(); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java new file mode 100644 index 0000000..4af8344 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java @@ -0,0 +1,201 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +import dev.amg.payguard.identity.verification.domain.exception.OtpAttemptCountOutOfRangeException; +import dev.amg.payguard.identity.verification.domain.exception.OtpConsumedTimeMustMatchUsedStatusException; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.time.Instant; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +public sealed interface OtpStatus + permits OtpStatus.Active, + OtpStatus.Used, + OtpStatus.Expired, + OtpStatus.Locked, + OtpStatus.Invalidated { + OtpStatus ACTIVE = new Active(); + OtpStatus USED = new Used(); + OtpStatus EXPIRED = new Expired(); + OtpStatus LOCKED = new Locked(); + OtpStatus INVALIDATED = new Invalidated(); + + String name(); + + Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher); + + default boolean canIssue() { + return false; + } + + default boolean canInvalidate() { + return false; + } + + default boolean expiresOnRejection() { + return false; + } + + default void validateAttempts(int attempts, int maxAttempts) {} + + default void validateConsumedAt( + @Nullable Instant consumedAt, Instant createdAt, Instant expiresAt) { + if (consumedAt != null) { + throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt); + } + } + + static OtpStatus valueOf(String name) { + return switch (name) { + case "ACTIVE" -> ACTIVE; + case "USED" -> USED; + case "EXPIRED" -> EXPIRED; + case "LOCKED" -> LOCKED; + case "INVALIDATED" -> INVALIDATED; + default -> throw new IllegalArgumentException("Unknown OTP status: " + name); + }; + } + + final class Active implements OtpStatus { + private Active() {} + + @Override + public String name() { + return "ACTIVE"; + } + + @Override + public boolean canIssue() { + return true; + } + + @Override + public boolean canInvalidate() { + return true; + } + + @Override + public boolean expiresOnRejection() { + return true; + } + + @Override + public void validateAttempts(int attempts, int maxAttempts) { + if (attempts == maxAttempts) { + throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts); + } + } + + @Override + public Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + if (command.now().isBefore(snapshot.createdAt())) { + throw new IllegalArgumentException("verification time must not precede challenge creation"); + } + if (!command.now().isBefore(snapshot.expiresAt())) { + return Optional.of(OtpVerificationResult.EXPIRED); + } + if (snapshot.deviceBindingHash() != null + && (command.deviceFingerprint() == null + || !hasher.matches(command.deviceFingerprint(), snapshot.deviceBindingHash()))) { + return Optional.of(OtpVerificationResult.DEVICE_MISMATCH); + } + return Optional.empty(); + } + + @Override + public String toString() { + return name(); + } + } + + final class Used implements OtpStatus { + private Used() {} + + @Override + public String name() { + return "USED"; + } + + @Override + public Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + return Optional.of(OtpVerificationResult.ALREADY_USED); + } + + @Override + public void validateConsumedAt( + @Nullable Instant consumedAt, Instant createdAt, Instant expiresAt) { + if (consumedAt == null) { + throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt); + } + } + + @Override + public String toString() { + return name(); + } + } + + final class Expired implements OtpStatus { + private Expired() {} + + @Override + public String name() { + return "EXPIRED"; + } + + @Override + public Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + return Optional.of(OtpVerificationResult.EXPIRED); + } + + @Override + public String toString() { + return name(); + } + } + + final class Locked implements OtpStatus { + private Locked() {} + + @Override + public String name() { + return "LOCKED"; + } + + @Override + public Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + return Optional.of(OtpVerificationResult.LOCKED); + } + + @Override + public String toString() { + return name(); + } + } + + final class Invalidated implements OtpStatus { + private Invalidated() {} + + @Override + public String name() { + return "INVALIDATED"; + } + + @Override + public Optional rejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + return Optional.of(OtpVerificationResult.INVALIDATED); + } + + @Override + public String toString() { + return name(); + } + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java similarity index 94% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java index cc6b5a5..f36505f 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java @@ -2,5 +2,4 @@ public enum OtpVerificationMode { RANDOM_NUMERIC, - TOTP } diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpExpiredEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpExpiredEvent.java new file mode 100644 index 0000000..a8ee55b --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpExpiredEvent.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Objects; + +/** Raised when an active OTP challenge transitions to EXPIRED. */ +public record OtpExpiredEvent( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) + implements DomainEvent { + + public OtpExpiredEvent { + Objects.requireNonNull(otpId, "otpId must not be null"); + Objects.requireNonNull(customerId, "customerId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(occurredOn, "occurredOn must not be null"); + } + + public static OtpExpiredEvent of( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) { + return new OtpExpiredEvent(otpId, customerId, purpose, occurredOn); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpInvalidatedEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpInvalidatedEvent.java new file mode 100644 index 0000000..52d307a --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpInvalidatedEvent.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Objects; + +/** Raised when an active OTP challenge is invalidated. */ +public record OtpInvalidatedEvent( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) + implements DomainEvent { + + public OtpInvalidatedEvent { + Objects.requireNonNull(otpId, "otpId must not be null"); + Objects.requireNonNull(customerId, "customerId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(occurredOn, "occurredOn must not be null"); + } + + public static OtpInvalidatedEvent of( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) { + return new OtpInvalidatedEvent(otpId, customerId, purpose, occurredOn); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpIssuedEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpIssuedEvent.java new file mode 100644 index 0000000..1385bba --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpIssuedEvent.java @@ -0,0 +1,36 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Objects; + +public record OtpIssuedEvent( + OtpId otpId, + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + Instant expiresAt, + Instant occurredOn) + implements DomainEvent { + public OtpIssuedEvent { + Objects.requireNonNull(otpId, "OTP Id cannot be null"); + Objects.requireNonNull(customerId, "Customer id cannot be null"); + Objects.requireNonNull(purpose, "Purpose cannot be null"); + Objects.requireNonNull(channel, "Channel cannot be null"); + Objects.requireNonNull(expiresAt, "Expires at cannot be null"); + Objects.requireNonNull(occurredOn, "Occurred on cannot be null"); + } + + public static OtpIssuedEvent of( + OtpId otpId, + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + Instant expiresAt) { + return new OtpIssuedEvent(otpId, customerId, purpose, channel, expiresAt, Instant.now()); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpLockedEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpLockedEvent.java new file mode 100644 index 0000000..c51c738 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpLockedEvent.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Objects; + +/** Raised when an OTP challenge is locked after exhausting its verification attempts. */ +public record OtpLockedEvent( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) + implements DomainEvent { + + public OtpLockedEvent { + Objects.requireNonNull(otpId, "otpId must not be null"); + Objects.requireNonNull(customerId, "customerId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(occurredOn, "occurredOn must not be null"); + } + + public static OtpLockedEvent of( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant occurredOn) { + return new OtpLockedEvent(otpId, customerId, purpose, occurredOn); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpVerificationEvent.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpVerificationEvent.java new file mode 100644 index 0000000..3a8c8b9 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpVerificationEvent.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.domain.event; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.DomainEvent; +import java.time.Instant; +import java.util.Objects; + +/** Completion result, not a bearer credential or authorization for a financial operation. */ +public record OtpVerificationEvent( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant verifiedAt, Instant occurredOn) + implements DomainEvent { + public OtpVerificationEvent { + Objects.requireNonNull(otpId, "OTP Id cannot be null"); + Objects.requireNonNull(customerId, "Customer id cannot be null"); + Objects.requireNonNull(purpose, "Purpose cannot be null"); + Objects.requireNonNull(verifiedAt, "Verification time cannot be null"); + Objects.requireNonNull(occurredOn, "Verification time cannot be null"); + } + + public static OtpVerificationEvent of( + OtpId otpId, CustomerId customerId, OtpPurpose purpose, Instant verifiedAt) { + return new OtpVerificationEvent(otpId, customerId, purpose, verifiedAt, Instant.now()); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java similarity index 84% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java index ef4a593..90a99b7 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java @@ -9,7 +9,7 @@ public class OtpRetentionPeriodMustNotPrecedeExpirationException extends Runtime public static final String MESSAGE = "OTP retention period is invalid: retentionUntil must be after expiresAt. " - + "The OTP record cannot be scheduled for removal before or at the same time as its expiration. " + + "The OTP recordAudit cannot be scheduled for removal before or at the same time as its expiration. " + "retentionUntil=%s, expiresAt=%s"; public OtpRetentionPeriodMustNotPrecedeExpirationException( diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java similarity index 67% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java index 2fe39e0..14bdd73 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java @@ -3,13 +3,18 @@ import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; import dev.amg.payguard.identity.customer.domain.value_object.OtpId; import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; -import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.event.OtpExpiredEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpInvalidatedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpIssuedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpLockedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpVerificationEvent; import dev.amg.payguard.identity.verification.domain.policy.OtpVerificationPolicy; import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; import domain.AggregateRoot; import domain.DomainEvent; +import java.time.Instant; import java.util.Objects; import java.util.Optional; @@ -76,35 +81,57 @@ public static OTP issue(OtpId id, CustomerId customerId, OtpSnapshot snapshot) { Objects.requireNonNull(id, "id must not be null"); Objects.requireNonNull(customerId, "customerId must not be null"); - if (snapshot.status() != OtpStatus.ACTIVE || snapshot.attempts() != 0) { + if (!snapshot.status().canIssue() || snapshot.attempts() != 0) { throw new IllegalArgumentException("issuance requires an initial snapshot"); } - return new OTP(id, customerId, snapshot); + final var otp = new OTP(id, customerId, snapshot); + var event = + OtpIssuedEvent.of( + id, customerId, snapshot.purpose(), snapshot.channel(), snapshot.expiresAt()); + otp.registerEvent(event); + return otp; } public static OTP restore(OtpId id, CustomerId customerId, OtpSnapshot snapshot) { return new OTP(id, customerId, snapshot); } - private static boolean isCodeValid( - VerifyOtpCommand command, OtpCodeHasher hasher, OtpSnapshot snapshot) { - if (snapshot.verificationMode() == OtpVerificationMode.TOTP) { - return command.authenticatorCodeValid(); - } - - return snapshot.codeHash() != null && hasher.matches(command.code(), snapshot.codeHash()); - } - + /** + * Verifies this OTP challenge and applies the resulting state transition. + * + *

A successful verification consumes the challenge exactly once. Invalid codes increment the + * attempt count, and the final failed attempt locks the challenge. Rejected requests do not + * consume attempts. + * + *

Registers an {@link OtpVerificationEvent} event only after a successful verification. + * + *

This method is not thread-safe. Concurrent mutations of the same challenge must be + * coordinated at the persistence boundary. + * + * @param command the verification request + * @param hasher the code verification strategy + * @return the result of the verification attempt + * @throws IllegalArgumentException if the verification time precedes challenge creation + */ public OtpVerificationResult verify(VerifyOtpCommand command, OtpCodeHasher hasher) { Optional rejection = OtpVerificationPolicy.findRejection(command, snapshot, hasher); if (rejection.isPresent()) { OtpVerificationResult result = rejection.get(); - if (result == OtpVerificationResult.EXPIRED && snapshot.status() == OtpStatus.ACTIVE) { - this.snapshot = this.snapshot.withStatus(OtpStatus.EXPIRED); + + if (result == OtpVerificationResult.EXPIRED && snapshot.status().expiresOnRejection()) { + + OtpSnapshot expiredSnapshot = snapshot.withStatus(OtpStatus.EXPIRED); + + OtpExpiredEvent event = + OtpExpiredEvent.of(getId(), customerId, snapshot.purpose(), command.now()); + + this.snapshot = expiredSnapshot; + registerEvent(event); } + return result; } boolean codeMatches = isCodeValid(command, hasher, snapshot); @@ -115,16 +142,30 @@ public OtpVerificationResult verify(VerifyOtpCommand command, OtpCodeHasher hash this.snapshot = lastAllowedAttempt ? snapshot.withFailedAttemptAndLock() : snapshot.withAttemptRecorded(); + if (lastAllowedAttempt) { + OtpLockedEvent otpLockedEvent = + OtpLockedEvent.of(getId(), customerId, snapshot.purpose(), command.now()); + registerEvent(otpLockedEvent); + } + return lastAllowedAttempt ? OtpVerificationResult.LOCKED : OtpVerificationResult.INVALID_CODE; } this.snapshot = snapshot.withSuccessfulAttempt(command.now()); + var otpVerificationEvent = + OtpVerificationEvent.of(getId(), getCustomerId(), getSnapshot().purpose(), command.now()); + registerEvent(otpVerificationEvent); return OtpVerificationResult.VERIFIED; } - public void invalidate() { - if (snapshot.status() == OtpStatus.ACTIVE) { + public void invalidate(Instant occurredOn) { + Objects.requireNonNull(occurredOn, "occurredOn must not be null"); + + if (snapshot.status().canInvalidate()) { this.snapshot = this.snapshot.withStatus(OtpStatus.INVALIDATED); + final var otpInvalidatedEvent = + OtpInvalidatedEvent.of(getId(), customerId, snapshot.purpose(), occurredOn); + registerEvent(otpInvalidatedEvent); } } @@ -135,4 +176,10 @@ public CustomerId getCustomerId() { public OtpSnapshot getSnapshot() { return snapshot; } + + private static boolean isCodeValid( + VerifyOtpCommand command, OtpCodeHasher hasher, OtpSnapshot snapshot) { + + return snapshot.codeHash() != null && hasher.matches(command.code(), snapshot.codeHash()); + } } diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java similarity index 93% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java index 2132a17..defcc88 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java @@ -8,7 +8,6 @@ import dev.amg.payguard.identity.verification.domain.exception.OtpAttemptCountOutOfRangeException; import dev.amg.payguard.identity.verification.domain.exception.OtpCodeHashMustNotBeBlankException; import dev.amg.payguard.identity.verification.domain.exception.OtpCodeHashRequiredForRandomNumericVerificationException; -import dev.amg.payguard.identity.verification.domain.exception.OtpConsumedTimeMustMatchUsedStatusException; import dev.amg.payguard.identity.verification.domain.exception.OtpConsumedTimeOutsideValidityPeriodException; import dev.amg.payguard.identity.verification.domain.exception.OtpDeviceBindingHashMustNotBeBlankException; import dev.amg.payguard.identity.verification.domain.exception.OtpExpirationTimeMustBeAfterCreationTimeException; @@ -50,8 +49,8 @@ * *

Responsibility

* - *

This record validates the resulting state. The OTP aggregate decides whether a requested state - * transition is allowed. Constructing a valid snapshot does not prove that an OTP code was + *

This recordAudit validates the resulting state. The OTP aggregate decides whether a requested + * state transition is allowed. Constructing a valid snapshot does not prove that an OTP code was * verified. * * @implSpec Every constructor call validates the supplied state. The factory and builder use the @@ -138,10 +137,7 @@ private static void validateAttempts(OtpStatus status, int attempts, int maxAtte if (attempts < 0 || attempts > maxAttempts) { throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts); } - /* An ACTIVE OTP must have at least one attempt remaining. */ - if (status == OtpStatus.ACTIVE && attempts == maxAttempts) { - throw new OtpAttemptCountOutOfRangeException(attempts, maxAttempts); - } + status.validateAttempts(attempts, maxAttempts); } private static void validateVerificationData( @@ -169,12 +165,7 @@ private static void validateLifecycle( throw new OtpExpirationTimeMustBeAfterCreationTimeException(); } - final boolean isUsed = status == OtpStatus.USED; - final boolean hasConsumedAt = consumedAt != null; - - if (isUsed != hasConsumedAt) { - throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt); - } + status.validateConsumedAt(consumedAt, createdAt, expiresAt); if (consumedAt != null && (consumedAt.isBefore(createdAt) || !consumedAt.isBefore(expiresAt))) { throw new OtpConsumedTimeOutsideValidityPeriodException(consumedAt, createdAt, expiresAt); } diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java new file mode 100644 index 0000000..bda9686 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java @@ -0,0 +1,20 @@ +package dev.amg.payguard.identity.verification.domain.policy; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.util.Optional; + +public final class OtpVerificationPolicy { + private OtpVerificationPolicy() {} + + /** Guards that reject before an attempt is counted. Empty = eligible to be checked. */ + public static Optional findRejection( + VerifyOtpCommand command, OtpSnapshot snapshot, OtpCodeHasher hasher) { + if (!command.purpose().equals(snapshot.purpose())) { + return Optional.of(OtpVerificationResult.PURPOSE_MISMATCH); + } + return snapshot.status().rejection(command, snapshot, hasher); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeGenerator.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpCodeHasher.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java similarity index 55% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java index 7e0306d..26d43cc 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java @@ -18,9 +18,18 @@ public interface OtpRepository { void save(OTP otp); /** - * Locks and restores current persisted state, applies one local mutation, and commits before - * returning its result. The callback must not perform remote calls or throw a business rejection - * whose state transition should be committed. Storage failures roll back the mutation. + * Locks and restores the current persisted challenge, applies a mutation, and persists the + * resulting state within the caller's transaction. + * + *

The database row lock must remain held until the caller's transaction commits or rolls back. + * + *

The callback must not perform remote calls. Business rejections that modify challenge state + * must be returned as results rather than thrown. + * + * @param id the challenge identifier + * @param mutation the operation applied to the locked aggregate + * @param the mutation result type + * @return the mutation result, or empty if the challenge does not exist */ Optional withLockedChallenge(OtpId id, Function mutation); diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java new file mode 100644 index 0000000..bea269f --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/domain/value_object/VerifyOtpCommand.java @@ -0,0 +1,29 @@ +package dev.amg.payguard.identity.verification.domain.value_object; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import domain.ValueObject; +import java.time.Instant; +import java.util.Objects; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public record VerifyOtpCommand( + OtpPurpose purpose, String code, @Nullable String deviceFingerprint, Instant now) + implements ValueObject { + + public VerifyOtpCommand { + Objects.requireNonNull(purpose, "purpose must not be null"); + Objects.requireNonNull(code, "code must not be null"); + Objects.requireNonNull(now, "now must not be null"); + } + + // other represents another whole value object. Comparing it with deviceFingerprint cannot + // validate constructor arguments. + // Generated equals compares every component, handles nullable fingerprints, and returns false for + // null. + @Override + public boolean sameValueAs(@Nullable ValueObject other) { + return other instanceof VerifyOtpCommand; + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpController.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpController.java new file mode 100644 index 0000000..1f07568 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpController.java @@ -0,0 +1,92 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.command.issue_otp.IssueOtpCommand; +import dev.amg.payguard.identity.verification.application.command.issue_otp.IssueOtpCommandResponse; +import dev.amg.payguard.identity.verification.application.command.issue_otp.IssueOtpHandler; +import dev.amg.payguard.identity.verification.application.command.otp_verification.OtpVerificationCommand; +import dev.amg.payguard.identity.verification.application.command.otp_verification.OtpVerificationHandler; +import dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto.IssueOtpRequest; +import dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto.VerifyOtpRequest; +import dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto.VerifyOtpResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.Objects; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry point for OTP issuance and verification. + * + *

Converts external representations into application commands. Challenge lifecycle rules and + * persistence are owned by the corresponding application handlers and domain aggregate. + */ +@RestController +@RequestMapping("/identity/challenges") +public class OtpController { + + private final IssueOtpHandler issueOtpHandler; + private final OtpVerificationHandler verificationHandler; + + public OtpController( + IssueOtpHandler issueOtpHandler, OtpVerificationHandler verificationHandler) { + + this.issueOtpHandler = Objects.requireNonNull(issueOtpHandler); + this.verificationHandler = Objects.requireNonNull(verificationHandler); + } + + @PostMapping + public ResponseEntity issue( + @Valid @RequestBody IssueOtpRequest request, HttpServletRequest httpRequest) { + + var command = + new IssueOtpCommand( + CustomerId.parse(request.userId()), + request.purpose(), + request.channel(), + request.destinationRef(), + request.deviceFingerprint(), + sourceIp(httpRequest)); + + var result = issueOtpHandler.handle(command); + + return ResponseEntity.ok( + new IssueOtpCommandResponse( + result.otpId(), + result.purpose(), + result.channel(), + command.destinationRef(), + sourceIp(httpRequest), + result.expiresAt())); + } + + @PostMapping("/{challengeId}/verify") + public ResponseEntity verify( + @PathVariable("challengeId") String challengeId, + @Valid @RequestBody VerifyOtpRequest request, + HttpServletRequest httpRequest) { + + var command = + new OtpVerificationCommand( + OtpId.parse(challengeId), + CustomerId.parse(request.userId()), + request.purpose(), + request.code(), + request.deviceFingerprint(), + sourceIp(httpRequest)); + + var result = verificationHandler.handle(command); + + return ResponseEntity.ok( + new VerifyOtpResponse(result.challengeId().value(), result.purpose(), result.verifiedAt())); + } + + private static String sourceIp(HttpServletRequest request) { + return request.getRemoteAddr(); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpExceptionHandler.java similarity index 71% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpExceptionHandler.java index 95463cd..aa13f69 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/OtpExceptionHandler.java @@ -1,8 +1,9 @@ -package dev.amg.payguard.identity.verification.interfaces.rest; +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest; -import dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException; -import dev.amg.payguard.identity.verification.application.OtpRateLimitExceededException; -import dev.amg.payguard.identity.verification.application.OtpVerificationException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpChallengeNotFoundException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpRateLimitExceededException; +import dev.amg.payguard.identity.verification.application.command.otp_verification.exception.OtpVerificationException; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitUnavailableException; import java.time.Instant; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -22,6 +23,11 @@ ResponseEntity rateLimited(OtpRateLimitExceededException exceptio return response(HttpStatus.TOO_MANY_REQUESTS, "rate_limit_exceeded"); } + @ExceptionHandler(RateLimitUnavailableException.class) + ResponseEntity rateLimiterUnavailable(RateLimitUnavailableException exception) { + return response(HttpStatus.SERVICE_UNAVAILABLE, "rate_limit_unavailable"); + } + @ExceptionHandler(OtpVerificationException.class) ResponseEntity verificationFailed(OtpVerificationException exception) { HttpStatus status = diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpRequest.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpRequest.java new file mode 100644 index 0000000..61e3710 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpRequest.java @@ -0,0 +1,26 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto; + +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record IssueOtpRequest( + @NotBlank String userId, + @NotNull OtpPurpose purpose, + @NotNull OtpChannel channel, + @NotBlank String destinationRef, + @Nullable String deviceFingerprint) { + + @Override + public String toString() { + return "IssueOtpRequest[purpose=" + + purpose + + ", channel=" + + channel + + ", userId=[REDACTED]" + + ", destinationRef=[REDACTED]" + + ", deviceFingerprint=[REDACTED]]"; + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpResponse.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpResponse.java new file mode 100644 index 0000000..d47ccfb --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/IssueOtpResponse.java @@ -0,0 +1,8 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto; + +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; + +public record IssueOtpResponse( + String challengeId, OtpPurpose purpose, OtpChannel channel, Instant expiresAt) {} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpRequest.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpRequest.java new file mode 100644 index 0000000..31e5ec3 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpRequest.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record VerifyOtpRequest( + @NotBlank String userId, + @NotNull OtpPurpose purpose, + @NotBlank @Pattern(regexp = "\\d{6}") String code, + @NotBlank String deviceFingerprint) { + + @Override + public String toString() { + return "VerifyOtpRequest[purpose=" + + purpose + + ", userId=[REDACTED]" + + ", code=[REDACTED]" + + ", deviceFingerprint=[REDACTED]]"; + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpResponse.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpResponse.java new file mode 100644 index 0000000..a917185 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/VerifyOtpResponse.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import java.time.Instant; + +public record VerifyOtpResponse(String challengeId, OtpPurpose purpose, Instant verifiedAt) {} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/package-info.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/package-info.java new file mode 100644 index 0000000..f9bbd50 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/in/rest/dto/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.amg.payguard.identity.verification.infrastructure.adapter.in.rest.dto; + +import org.jspecify.annotations.NullMarked; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/EmailDeliveryAdapter.java similarity index 89% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/EmailDeliveryAdapter.java index 1d7843b..a20fc3e 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/EmailDeliveryAdapter.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/EmailDeliveryAdapter.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.infrastructure.delivery; +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.delivery; import dev.amg.payguard.identity.verification.application.port.OtpDelivery; import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/MockDeliveryAdapter.java similarity index 90% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/MockDeliveryAdapter.java index 23ef0c8..19cf555 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/MockDeliveryAdapter.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/MockDeliveryAdapter.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.infrastructure.delivery; +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.delivery; import dev.amg.payguard.identity.verification.application.port.OtpDelivery; import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/PushDeliveryAdapter.java similarity index 88% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/PushDeliveryAdapter.java index c55dad0..ab36726 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/PushDeliveryAdapter.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/PushDeliveryAdapter.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.infrastructure.delivery; +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.delivery; import dev.amg.payguard.identity.verification.application.port.OtpDelivery; import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/SmsDeliveryAdapter.java similarity index 88% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/SmsDeliveryAdapter.java index 1fa7c76..bd7e6dc 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/delivery/SmsDeliveryAdapter.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/delivery/SmsDeliveryAdapter.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.infrastructure.delivery; +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.delivery; import dev.amg.payguard.identity.verification.application.port.OtpDelivery; import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/kafka/KafkaOtpEventPublisher.java similarity index 95% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/kafka/KafkaOtpEventPublisher.java index 298a466..5278e4c 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/kafka/KafkaOtpEventPublisher.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/kafka/KafkaOtpEventPublisher.java @@ -1,4 +1,4 @@ -package dev.amg.payguard.identity.verification.infrastructure.kafka; +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.kafka; import dev.amg.payguard.identity.verification.domain.event.OtpEvent; import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOTPRepositoryAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOTPRepositoryAdapter.java new file mode 100644 index 0000000..8eb1590 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOTPRepositoryAdapter.java @@ -0,0 +1,227 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq; + +import static dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Tables.IDENTITY_OTP; +import static dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Tables.IDENTITY_OTP_SCOPE; +import static dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Tables.IDENTITY_OUTBOX; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.enums.IdentityStatus; +import dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.tables.records.IdentityOtpRecord; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import domain.DomainEvent; +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; +import org.jooq.DSLContext; +import org.jooq.JSON; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class JooqOTPRepositoryAdapter implements OtpRepository { + private static final int EXPECTED_AFFECTED_ROWS = 1; + + private final DSLContext dsl; + private final Clock clock; + private final OtpOutboxEncoder outboxEncoder; + + public JooqOTPRepositoryAdapter(DSLContext dsl, Clock clock, OtpOutboxEncoder outboxEncoder) { + this.dsl = dsl; + this.clock = clock; + this.outboxEncoder = outboxEncoder; + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void replaceActive(OTP replacement) { + Objects.requireNonNull(replacement); + String customerId = replacement.getCustomerId().getValue(); + String purpose = replacement.getSnapshot().purpose().name(); + + dsl.insertInto(IDENTITY_OTP_SCOPE) + .set(IDENTITY_OTP_SCOPE.USER_ID, customerId) + .set(IDENTITY_OTP_SCOPE.PURPOSE, purpose) + .onConflict(IDENTITY_OTP_SCOPE.USER_ID, IDENTITY_OTP_SCOPE.PURPOSE) + .doNothing() + .execute(); + + final var scope = + dsl.select(IDENTITY_OTP_SCOPE.USER_ID) + .from(IDENTITY_OTP_SCOPE) + .where(IDENTITY_OTP_SCOPE.USER_ID.eq(customerId)) + .and(IDENTITY_OTP_SCOPE.PURPOSE.eq(purpose)) + .forUpdate() + .fetchOne(); + + if (Objects.isNull(scope)) { + throw new IllegalStateException("OTP issuance scope not found"); + } + + final IdentityOtpRecord previousRow = + dsl.selectFrom(IDENTITY_OTP) + .where(IDENTITY_OTP.USER_ID.eq(customerId)) + .and(IDENTITY_OTP.PURPOSE.eq(purpose)) + .and(IDENTITY_OTP.STATUS.eq(IdentityStatus.ACTIVE)) + .forUpdate() + .fetchOne(); + + if (!Objects.isNull(previousRow)) { + OTP previous = restore(previousRow); + + Instant invalidatedAt = clock.instant().truncatedTo(ChronoUnit.MICROS); + + previous.invalidate(invalidatedAt); + + updateState(previous); + appendOutbox(previous); + } + + insert(replacement); + appendOutbox(replacement); + } + + @Override + public void save(OTP otp) { + Objects.requireNonNull(otp); + insert(otp); + appendOutbox(otp); + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public Optional withLockedChallenge(OtpId id, Function mutation) { + Objects.requireNonNull(id); + Objects.requireNonNull(mutation); + IdentityOtpRecord row = + dsl.selectFrom(IDENTITY_OTP).where(IDENTITY_OTP.ID.eq(id.value())).forUpdate().fetchOne(); + if (row == null) { + return Optional.empty(); + } + OTP otp = restore(row); + T result = Objects.requireNonNull(mutation.apply(otp), "OTP mutation result must not be null"); + updateState(otp); + appendOutbox(otp); + return Optional.of(result); + } + + @Override + public Optional findById(OtpId otpId) { + Objects.requireNonNull(otpId); + + return dsl.selectFrom(IDENTITY_OTP) + .where(IDENTITY_OTP.ID.eq(otpId.value())) + .fetchOptional() + .map(JooqOTPRepositoryAdapter::restore); + } + + private void insert(OTP otp) { + OtpSnapshot snapshot = otp.getSnapshot(); + + int inserted = + dsl.insertInto(IDENTITY_OTP) + .set(IDENTITY_OTP.ID, otp.getId().value()) + .set(IDENTITY_OTP.USER_ID, otp.getCustomerId().getValue()) + .set(IDENTITY_OTP.PURPOSE, snapshot.purpose().name()) + .set(IDENTITY_OTP.CHANNEL, snapshot.channel().name()) + .set(IDENTITY_OTP.VERIFICATION_MODE, snapshot.verificationMode().name()) + .set(IDENTITY_OTP.CODE_HASH, snapshot.codeHash()) + .set(IDENTITY_OTP.DEVICE_BINDING_HASH, snapshot.deviceBindingHash()) + .set(IDENTITY_OTP.ATTEMPTS, snapshot.attempts()) + .set(IDENTITY_OTP.MAX_ATTEMPTS, snapshot.maxAttempts()) + .set(IDENTITY_OTP.STATUS, databaseStatus(snapshot.status())) + .set(IDENTITY_OTP.CREATED_AT, utc(snapshot.createdAt())) + .set(IDENTITY_OTP.EXPIRES_AT, utc(snapshot.expiresAt())) + .set(IDENTITY_OTP.CONSUMED_AT, nullableUtc(snapshot.consumedAt())) + .set(IDENTITY_OTP.RETENTION_UNTIL, utc(snapshot.retentionUntil())) + .execute(); + + if (inserted != EXPECTED_AFFECTED_ROWS) { + throw new IllegalStateException("Failed to insert OTP challenge"); + } + } + + private void appendOutbox(OTP otp) { + // Pull all otp related events + for (DomainEvent event : otp.pullDomainEvents()) { + final OtpOutboxEncoder.EncodedEvent encodedEvent = outboxEncoder.encode(event); + dsl.insertInto(IDENTITY_OUTBOX) + .set(IDENTITY_OUTBOX.EVENT_ID, UUID.randomUUID()) + .set(IDENTITY_OUTBOX.AGGREGATE_ID, otp.getId().value()) + .set(IDENTITY_OUTBOX.EVENT_TYPE, encodedEvent.eventType()) + .set(IDENTITY_OUTBOX.PAYLOAD, JSON.valueOf(encodedEvent.json())) + .set(IDENTITY_OUTBOX.OCCURRED_AT, utc(event.occurredOn())) + .execute(); + } + } + + /** + * Persists the mutable lifecycle fields. + * + *

All updates to an existing challenge must occur after its row has been locked by the calling + * operation. + */ + private void updateState(OTP otp) { + OtpSnapshot snapshot = otp.getSnapshot(); + + int updated = + dsl.update(IDENTITY_OTP) + .set(IDENTITY_OTP.STATUS, databaseStatus(snapshot.status())) + .set(IDENTITY_OTP.ATTEMPTS, snapshot.attempts()) + .set(IDENTITY_OTP.CONSUMED_AT, nullableUtc(snapshot.consumedAt())) + .where(IDENTITY_OTP.ID.eq(otp.getId().value())) + .execute(); + + if (updated != EXPECTED_AFFECTED_ROWS) { + throw new IllegalStateException("Failed to update OTP challenge"); + } + } + + private static IdentityStatus databaseStatus(OtpStatus status) { + return IdentityStatus.valueOf(status.name()); + } + + private static OffsetDateTime utc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private static OffsetDateTime nullableUtc(Instant instant) { + return instant == null ? null : utc(instant); + } + + private static OTP restore(IdentityOtpRecord row) { + final OtpSnapshot snapshot = + OtpSnapshot.create( + OtpPurpose.valueOf(row.get(IDENTITY_OTP.PURPOSE)), + OtpChannel.valueOf(row.get(IDENTITY_OTP.CHANNEL)), + OtpVerificationMode.valueOf(row.get(IDENTITY_OTP.VERIFICATION_MODE)), + row.get(IDENTITY_OTP.CODE_HASH), + row.get(IDENTITY_OTP.DEVICE_BINDING_HASH), + row.get(IDENTITY_OTP.MAX_ATTEMPTS), + row.get(IDENTITY_OTP.CREATED_AT).toInstant(), + row.get(IDENTITY_OTP.EXPIRES_AT).toInstant(), + row.get(IDENTITY_OTP.RETENTION_UNTIL).toInstant(), + row.get(IDENTITY_OTP.ATTEMPTS), + OtpStatus.valueOf(row.get(IDENTITY_OTP.STATUS).getLiteral()), + row.get(IDENTITY_OTP.CONSUMED_AT) == null + ? null + : row.get(IDENTITY_OTP.CONSUMED_AT).toInstant()); + return OTP.restore( + OtpId.parse(row.get(IDENTITY_OTP.ID)), + CustomerId.parse(row.get(IDENTITY_OTP.USER_ID)), + snapshot); + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOtpAuditLogAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOtpAuditLogAdapter.java new file mode 100644 index 0000000..c359de8 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JooqOtpAuditLogAdapter.java @@ -0,0 +1,3 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq; + +public class JooqOtpAuditLogAdapter {} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JsonOtpOutboxEncoder.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JsonOtpOutboxEncoder.java new file mode 100644 index 0000000..f5969c5 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/JsonOtpOutboxEncoder.java @@ -0,0 +1,83 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq; + +import dev.amg.payguard.identity.verification.domain.event.OtpExpiredEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpInvalidatedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpIssuedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpLockedEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpVerificationEvent; +import domain.DomainEvent; +import java.util.Map; +import org.springframework.stereotype.Component; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Component +public final class JsonOtpOutboxEncoder implements OtpOutboxEncoder { + private static final String OTP_ID = "otpId"; + private static final String CUSTOMER_ID = "customerId"; + private static final String PURPOSE = "purpose"; + private static final String OCCURRED_ON = "occurredOn"; + private final ObjectMapper mapper; + + public JsonOtpOutboxEncoder(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public EncodedEvent encode(DomainEvent event) { + Map payload = + switch (event) { + case OtpIssuedEvent issued -> + Map.of( + OTP_ID, + issued.otpId().value(), + CUSTOMER_ID, + issued.customerId().getValue(), + PURPOSE, + issued.purpose().name(), + "channel", + issued.channel().name(), + "expiresAt", + issued.expiresAt().toString(), + OCCURRED_ON, + issued.occurredOn().toString()); + case OtpVerificationEvent verified -> + Map.of( + OTP_ID, + verified.otpId().value(), + CUSTOMER_ID, + verified.customerId().getValue(), + PURPOSE, + verified.purpose().name(), + "verifiedAt", + verified.verifiedAt().toString(), + OCCURRED_ON, + verified.occurredOn().toString()); + case OtpExpiredEvent expired -> + Map.of( + OTP_ID, expired.otpId().value(), + CUSTOMER_ID, expired.customerId().getValue(), + PURPOSE, expired.purpose().name(), + OCCURRED_ON, expired.occurredOn().toString()); + case OtpLockedEvent locked -> + Map.of( + OTP_ID, locked.otpId().value(), + CUSTOMER_ID, locked.customerId().getValue(), + PURPOSE, locked.purpose().name(), + OCCURRED_ON, locked.occurredOn().toString()); + case OtpInvalidatedEvent invalidated -> + Map.of( + OTP_ID, invalidated.otpId().value(), + CUSTOMER_ID, invalidated.customerId().getValue(), + PURPOSE, invalidated.purpose().name(), + OCCURRED_ON, invalidated.occurredOn().toString()); + default -> + throw new IllegalArgumentException("Unsupported OTP event: " + event.getClass()); + }; + try { + return new EncodedEvent(event.getClass().getSimpleName(), mapper.writeValueAsString(payload)); + } catch (JacksonException exception) { + throw new IllegalStateException("Unable to encode OTP outbox event", exception); + } + } +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/OtpOutboxEncoder.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/OtpOutboxEncoder.java new file mode 100644 index 0000000..64838d4 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/jooq/OtpOutboxEncoder.java @@ -0,0 +1,17 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq; + +import domain.DomainEvent; + +/** + * Converts a Domain Event to a versioned outbox message. + * + *

Implementations must expose only approved event fields. OTP codes, hashes, and device + * fingerprints must not be serialized. + */ +@FunctionalInterface +public interface OtpOutboxEncoder { + + EncodedEvent encode(DomainEvent event); + + record EncodedEvent(String eventType, String json) {} +} diff --git a/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/redis/rate_limit/RedisRateLimiterAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/redis/rate_limit/RedisRateLimiterAdapter.java new file mode 100644 index 0000000..d105543 --- /dev/null +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/adapter/out/persistence/redis/rate_limit/RedisRateLimiterAdapter.java @@ -0,0 +1,118 @@ +package dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.redis.rate_limit; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitDecision; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPolicy; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPort; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitUnavailableException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +@Component +public class RedisRateLimiterAdapter implements RateLimitPort { + private static final DefaultRedisScript ACQUIRE_SCRIPT = new DefaultRedisScript<>(); + + static { + ACQUIRE_SCRIPT.setLocation(new ClassPathResource("redis/scripts/token_bucket.lua")); + ACQUIRE_SCRIPT.setResultType(List.class); + } + + private final StringRedisTemplate redis; + private final int maxIssues; + private final int maxVerifications; + private final Duration window; + + public RedisRateLimiterAdapter( + StringRedisTemplate redis, + @Value("${identity.rate-limit.max-issues:3}") int maxIssues, + @Value("${identity.rate-limit.max-verifications:20}") int maxVerifications, + @Value("${identity.rate-limit.window:PT15M}") Duration window) { + this.redis = Objects.requireNonNull(redis, "redis"); + Objects.requireNonNull(window, "window"); + if (maxIssues < 1 || maxVerifications < 1 || window.isNegative() || window.isZero()) { + throw new IllegalArgumentException("OTP rate limits and window must be positive"); + } + try { + if (window.toMillis() <= 0) { + throw new IllegalArgumentException( + "OTP rate-limit window must be at least one millisecond"); + } + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("OTP rate-limit window is too large", exception); + } + this.maxIssues = maxIssues; + this.maxVerifications = maxVerifications; + this.window = window; + } + + @Override + public RateLimitDecision acquire(RateLimitRequest request) { + int maximum = + switch (request.operation()) { + case ISSUE, RESEND -> maxIssues; + case VERIFY -> maxVerifications; + }; + String prefix = "identity:rate:" + request.operation().name().toLowerCase(Locale.ROOT); + List keys = new ArrayList<>(); + List arguments = new ArrayList<>(); + addBucket(keys, arguments, prefix + ":ip:" + safe(request.ipAddress()), "ip", maximum); + if (request.customerId() != null) { + addBucket( + keys, + arguments, + prefix + ":customer:" + request.purpose() + ":" + safe(request.customerId()), + "customer", + maximum); + } + if (request.recipientFingerprint() != null) { + addBucket( + keys, + arguments, + prefix + ":recipient:" + request.purpose() + ":" + safe(request.recipientFingerprint()), + "recipient", + maximum); + } + try { + List result = redis.execute(ACQUIRE_SCRIPT, keys, arguments.toArray()); + Objects.requireNonNull(result, "result cannot be null"); + if (result.size() != 3 + || !(result.get(0) instanceof Long admitted) + || !(result.get(1) instanceof Long retryMillis) + || !(result.get(2) instanceof String policyName)) { + throw new RateLimitUnavailableException("Redis did not return a valid rate-limit decision"); + } + if (admitted == 1 && retryMillis == 0 && policyName.isEmpty()) { + return RateLimitDecision.allowed(); + } + if (admitted == 0 && retryMillis > 0 && !policyName.isBlank()) { + return RateLimitDecision.rejected(Duration.ofMillis(retryMillis), policyName); + } + throw new RateLimitUnavailableException("Redis returned an invalid rate-limit decision"); + } catch (DataAccessException exception) { + throw new RateLimitUnavailableException("OTP rate limiter is unavailable", exception); + } + } + + private void addBucket( + List keys, List arguments, String key, String name, int maximum) { + RateLimitPolicy policy = new RateLimitPolicy(name, maximum, maximum, window); + keys.add(key); + arguments.add(policy.name()); + arguments.add(Long.toString(policy.capacity())); + arguments.add(Long.toString(policy.refillTokens())); + arguments.add(Long.toString(policy.refillPeriod().toMillis())); + } + + private static String safe(String value) { + return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java similarity index 76% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java index 0d9452a..25c01e8 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java @@ -1,11 +1,10 @@ package dev.amg.payguard.identity.verification.infrastructure.config; -import dev.amg.payguard.identity.verification.application.OtpApplicationService; +import dev.amg.payguard.identity.verification.application.command.issue_otp.IssueOtpHandler; +import dev.amg.payguard.identity.verification.application.command.otp_verification.OtpVerificationHandler; import dev.amg.payguard.identity.verification.application.port.OtpDelivery; -import dev.amg.payguard.identity.verification.application.port.TotpVerification; import dev.amg.payguard.identity.verification.application.port.VerificationAudit; -import dev.amg.payguard.identity.verification.application.port.VerificationQuota; -import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPort; import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; import dev.amg.payguard.identity.verification.domain.port.OtpRepository; @@ -32,7 +31,7 @@ org.springframework.boot.health.contributor.HealthIndicator otpKeysHealthIndicat ? org.springframework.boot.health.contributor.Health.up() : org.springframework.boot.health.contributor.Health.down()) .build(); - } catch (infrastructure.vault.VaultUnavailableException exception) { + } catch (infrastructure.vault.VaultUnavailableException _) { return org.springframework.boot.health.contributor.Health.down().build(); } }; @@ -65,35 +64,41 @@ Clock identityClock() { } @Bean - OtpApplicationService identityApplicationService( - OtpRepository challenges, - OtpCodeHasher hasher, + IssueOtpHandler issueOtpHandler( + OtpRepository repository, OtpCodeGenerator generator, - TotpVerification authenticatorCodeVerifier, + OtpCodeHasher hasher, + RateLimitPort quota, OtpDelivery delivery, - VerificationQuota rateLimiter, - OtpIdGenerator ids, - OtpEventPublisher events, VerificationAudit audit, + OtpIdGenerator ids, Clock identityClock, - @Value("${identity.challenge.random-ttl:PT5M}") Duration randomTtl, - @Value("${identity.challenge.totp-ttl:PT30S}") Duration totpTtl, + @Value("${identity.challenge.random-ttl:PT5M}") Duration ttl, @Value("${identity.audit.retention:P1097D}") Duration auditRetention, @Value("${identity.challenge.max-attempts:5}") int maxAttempts) { - return new OtpApplicationService( - challenges, - hasher, + + return new IssueOtpHandler( + repository, generator, - authenticatorCodeVerifier, + hasher, + quota, delivery, - rateLimiter, - ids::generate, - events, audit, + ids::generate, identityClock, - randomTtl, - totpTtl, + ttl, auditRetention, maxAttempts); } + + @Bean + OtpVerificationHandler otpVerificationHandler( + OtpRepository repository, + OtpCodeHasher hasher, + RateLimitPort quota, + VerificationAudit audit, + Clock identityClock) { + + return new OtpVerificationHandler(repository, hasher, quota, audit, identityClock); + } } diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/OtpIdGenerator.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/SecureNumericOtpGenerator.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/UnavailableAuthenticatorSecretAdapter.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java similarity index 100% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java similarity index 99% rename from payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java rename to payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java index 655eb5f..34fa1e9 100644 --- a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java +++ b/payguard-identity/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java @@ -23,6 +23,7 @@ public final class VaultPepperKeys implements Supplier, AutoCloseable { private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(VaultPepperKeys.class); + private final VaultClient vault; private final String prefix; private final Duration compatibility; @@ -51,6 +52,23 @@ public VaultPepperKeys( scheduler.scheduleWithFixedDelay(this::refresh, 5, 5, TimeUnit.SECONDS); } + @Override + public PepperKeyRing get() { + if (Instant.now().isAfter(fetchedAt.plus(Duration.ofMinutes(5)))) { + throw new VaultUnavailableException(); + } + return ring; + } + + @Override + public void close() { + scheduler.shutdownNow(); + } + + public boolean refreshHealthy() { + return latestRefreshSucceeded; + } + private PepperKeyRing fetch() { try { var data = vault.read("secret/data/" + prefix + "/otp-keys").path("data").path("data"); @@ -106,21 +124,4 @@ private void validateLegacySelection(PepperKeyRing next, Instant now) { } } } - - @Override - public PepperKeyRing get() { - if (Instant.now().isAfter(fetchedAt.plus(Duration.ofMinutes(5)))) { - throw new VaultUnavailableException(); - } - return ring; - } - - @Override - public void close() { - scheduler.shutdownNow(); - } - - public boolean refreshHealthy() { - return latestRefreshSucceeded; - } } diff --git a/payguard-identity-service/src/main/resources/application.yaml b/payguard-identity/src/main/resources/application.yaml similarity index 66% rename from payguard-identity-service/src/main/resources/application.yaml rename to payguard-identity/src/main/resources/application.yaml index 9aee22a..8d5a15b 100644 --- a/payguard-identity-service/src/main/resources/application.yaml +++ b/payguard-identity/src/main/resources/application.yaml @@ -10,12 +10,6 @@ spring: validation-timeout: 2000 max-lifetime: 300000 keepalive-time: 30000 - jpa: - properties: - hibernate.default_schema: payguard - open-in-view: false - hibernate: - ddl-auto: validate flyway: enabled: false data: @@ -27,11 +21,9 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer - server: - shutdown: graceful port: ${OTP_SERVICE_PORT:8084} - + shutdown: graceful identity: delivery: mode: ${OTP_DELIVERY_MODE:mock} @@ -47,7 +39,6 @@ identity: retention: ${OTP_AUDIT_RETENTION:P1097D} kafka: topic: ${OTP_KAFKA_TOPIC:identity.events.v1} - payguard: vault: enabled: true @@ -55,7 +46,44 @@ payguard: ca-file: ${VAULT_CA_FILE} token-file: ${VAULT_TOKEN_FILE} secret-prefix: ${PAYGUARD_ENVIRONMENT:local}/identity - + otp: + rate-limit: + enabled: true + global: + capacity: 200 + refill-tokens: 100 + refill-period: 1s + issue: + endpoint: + capacity: 25 + refill-tokens: 25 + refill-period: 1s + customer: + capacity: 3 + refill-tokens: 1 + refill-period: 5m + ip: + capacity: 10 + refill-tokens: 10 + refill-period: 1m + verify: + endpoint: + capacity: 75 + refill-tokens: 75 + refill-period: 1s + customer: + capacity: 5 + refill-tokens: 5 + refill-period: 1m + ip: + capacity: 30 + refill-tokens: 30 + refill-period: 1m + resend: + customer: + capacity: 1 + refill-tokens: 1 + refill-period: 60s management: endpoint: health: @@ -68,5 +96,4 @@ management: web: exposure: include: health,info - spring.lifecycle.timeout-per-shutdown-phase: 20s diff --git a/payguard-identity-service/src/main/resources/banner.txt b/payguard-identity/src/main/resources/banner.txt similarity index 100% rename from payguard-identity-service/src/main/resources/banner.txt rename to payguard-identity/src/main/resources/banner.txt diff --git a/payguard-identity/src/main/resources/db/jooq/V1__create_identity_otp_scope.sql b/payguard-identity/src/main/resources/db/jooq/V1__create_identity_otp_scope.sql new file mode 100644 index 0000000..3bd5db7 --- /dev/null +++ b/payguard-identity/src/main/resources/db/jooq/V1__create_identity_otp_scope.sql @@ -0,0 +1,13 @@ +-- Serializes issuance by customer and purpose, +-- including when no OTP challenge exists yet. + +CREATE TABLE identity_otp_scope +( + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + + PRIMARY KEY (user_id, purpose), + + CONSTRAINT ck_identity_scope_customer_id + CHECK (user_id ~ '^cus_[A-Za-z0-9]{22}$') +); \ No newline at end of file diff --git a/payguard-identity/src/main/resources/db/jooq/V2__create_identity_otp.sql b/payguard-identity/src/main/resources/db/jooq/V2__create_identity_otp.sql new file mode 100644 index 0000000..b9b4766 --- /dev/null +++ b/payguard-identity/src/main/resources/db/jooq/V2__create_identity_otp.sql @@ -0,0 +1,82 @@ +CREATE TYPE identity_status AS ENUM ( + 'ACTIVE', + 'USED', + 'LOCKED', + 'EXPIRED', + 'INVALIDATED' + ); + +CREATE TABLE identity_otp +( + id varchar(26) COLLATE "C" PRIMARY KEY, + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + channel varchar(32) NOT NULL, + verification_mode varchar(32) NOT NULL, + code_hash varchar(512), + device_binding_hash varchar(512), + attempts integer DEFAULT 0 NOT NULL, + max_attempts integer NOT NULL, + status identity_status NOT NULL, + created_at timestamptz(6) NOT NULL, + expires_at timestamptz(6) NOT NULL, + consumed_at timestamptz(6), + retention_until timestamptz(6) NOT NULL, + + CONSTRAINT ck_identity_otp_id + CHECK (id ~ '^otp_[A-Za-z0-9]{22}$'), + + CONSTRAINT ck_identity_customer_id + CHECK (user_id ~ '^cus_[A-Za-z0-9]{22}$'), + + CONSTRAINT ck_identity_attempts + CHECK (max_attempts > 0 + AND attempts BETWEEN 0 AND max_attempts), + + CONSTRAINT ck_identity_active_attempts + CHECK (status <> 'ACTIVE' OR attempts < max_attempts), + + CONSTRAINT ck_identity_expiry + CHECK (expires_at > created_at), + + CONSTRAINT ck_identity_consumption + CHECK ((status = 'USED') = (consumed_at IS NOT NULL)), + + CONSTRAINT ck_identity_consumption_time + CHECK ( + consumed_at IS NULL + OR (consumed_at >= created_at AND consumed_at < expires_at) + ), + + CONSTRAINT ck_identity_code_hash + CHECK ( + verification_mode <> 'RANDOM_NUMERIC' + OR (code_hash IS NOT NULL AND length(btrim(code_hash)) > 0) + ), + + CONSTRAINT ck_identity_binding_hash + CHECK ( + device_binding_hash IS NULL + OR length(btrim(device_binding_hash)) > 0 + ) + + -- [jooq ignore start] + , + CONSTRAINT ck_identity_retention + CHECK ( + retention_until >= ( + ( + (expires_at AT TIME ZONE 'UTC') + + INTERVAL '3 years' + ) AT TIME ZONE 'UTC' + ) + ) + -- [jooq ignore stop] +); + +CREATE UNIQUE INDEX ux_identity_otp_active + ON identity_otp (user_id, purpose) + WHERE status = 'ACTIVE'; + +CREATE INDEX ix_identity_otp_retention + ON identity_otp (retention_until); diff --git a/payguard-identity/src/main/resources/db/jooq/V3__create_identity_audit_log.sql b/payguard-identity/src/main/resources/db/jooq/V3__create_identity_audit_log.sql new file mode 100644 index 0000000..3d1f2b5 --- /dev/null +++ b/payguard-identity/src/main/resources/db/jooq/V3__create_identity_audit_log.sql @@ -0,0 +1,17 @@ +CREATE TABLE identity_audit_log +( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + otp_id varchar(26) COLLATE "C" NOT NULL + REFERENCES identity_otp (id), + user_id varchar(255) COLLATE "C" NOT NULL, + action varchar(32) NOT NULL, + reason varchar(128) NOT NULL, + source_ip varchar(64), + occurred_at timestamptz(6) NOT NULL +); + +CREATE INDEX ix_identity_audit_subject_time + ON identity_audit_log (user_id, occurred_at); + +CREATE INDEX ix_identity_audit_otp + ON identity_audit_log (otp_id, occurred_at); \ No newline at end of file diff --git a/payguard-identity/src/main/resources/db/jooq/V4__create_identity_outbox.sql b/payguard-identity/src/main/resources/db/jooq/V4__create_identity_outbox.sql new file mode 100644 index 0000000..04ec93e --- /dev/null +++ b/payguard-identity/src/main/resources/db/jooq/V4__create_identity_outbox.sql @@ -0,0 +1,22 @@ +CREATE TABLE identity_outbox +( + event_id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + aggregate_id varchar(26) COLLATE "C" NOT NULL, + event_type varchar(128) NOT NULL, + payload jsonb NOT NULL, + occurred_at timestamptz(6) NOT NULL, + created_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + published_at timestamptz(6), + next_attempt_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + attempt_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT ck_identity_outbox_payload + CHECK (jsonb_typeof(payload) = 'object'), + + CONSTRAINT ck_identity_outbox_attempts + CHECK (attempt_count >= 0) +); + +CREATE INDEX ix_identity_outbox_pending + ON identity_outbox (next_attempt_at, created_at, event_id) + WHERE published_at IS NULL; \ No newline at end of file diff --git a/payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql b/payguard-identity/src/main/resources/db/migration/postgresql/V1__identity_schema.sql similarity index 100% rename from payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql rename to payguard-identity/src/main/resources/db/migration/postgresql/V1__identity_schema.sql diff --git a/payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql b/payguard-identity/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql similarity index 100% rename from payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql rename to payguard-identity/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql diff --git a/payguard-identity/src/main/resources/db/migration/postgresql/V3__retain_challenges_for_jooq.sql b/payguard-identity/src/main/resources/db/migration/postgresql/V3__retain_challenges_for_jooq.sql new file mode 100644 index 0000000..f1a2001 --- /dev/null +++ b/payguard-identity/src/main/resources/db/migration/postgresql/V3__retain_challenges_for_jooq.sql @@ -0,0 +1,19 @@ +-- Preserve V1/V2 history and rows while adopting the recovered jOOQ table names. +-- The old text status checks/index must be recreated against the enum type. +CREATE TYPE payguard.identity_status AS ENUM ('ACTIVE', 'USED', 'LOCKED', 'EXPIRED', 'INVALIDATED'); +ALTER TABLE payguard.identity_challenge + DROP CONSTRAINT ck_identity_status, + DROP CONSTRAINT ck_identity_active_attempts, + DROP CONSTRAINT ck_identity_consumption; +DROP INDEX payguard.ux_identity_one_active_challenge; +ALTER TABLE payguard.identity_challenge RENAME TO identity_otp; +ALTER TABLE payguard.identity_otp + ALTER COLUMN status TYPE payguard.identity_status USING status::text::payguard.identity_status, + ADD CONSTRAINT ck_identity_active_attempts CHECK (status <> 'ACTIVE' OR attempts < max_attempts), + ADD CONSTRAINT ck_identity_consumption CHECK ((status = 'USED') = (consumed_at IS NOT NULL)); +ALTER TABLE payguard.identity_challenge_scope RENAME TO identity_otp_scope; +ALTER TABLE payguard.identity_audit_log RENAME COLUMN challenge_id TO otp_id; +CREATE UNIQUE INDEX ux_identity_otp_active ON payguard.identity_otp(user_id, purpose) WHERE status = 'ACTIVE'; +ALTER INDEX payguard.ix_identity_challenge_active RENAME TO ix_identity_otp_subject_status; +ALTER INDEX payguard.ix_identity_challenge_retention RENAME TO ix_identity_otp_retention; +ALTER INDEX payguard.ix_identity_audit_challenge RENAME TO ix_identity_audit_otp; diff --git a/payguard-identity/src/main/resources/db/migration/postgresql/V4__create_identity_outbox.sql b/payguard-identity/src/main/resources/db/migration/postgresql/V4__create_identity_outbox.sql new file mode 100644 index 0000000..04ec93e --- /dev/null +++ b/payguard-identity/src/main/resources/db/migration/postgresql/V4__create_identity_outbox.sql @@ -0,0 +1,22 @@ +CREATE TABLE identity_outbox +( + event_id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + aggregate_id varchar(26) COLLATE "C" NOT NULL, + event_type varchar(128) NOT NULL, + payload jsonb NOT NULL, + occurred_at timestamptz(6) NOT NULL, + created_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + published_at timestamptz(6), + next_attempt_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + attempt_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT ck_identity_outbox_payload + CHECK (jsonb_typeof(payload) = 'object'), + + CONSTRAINT ck_identity_outbox_attempts + CHECK (attempt_count >= 0) +); + +CREATE INDEX ix_identity_outbox_pending + ON identity_outbox (next_attempt_at, created_at, event_id) + WHERE published_at IS NULL; \ No newline at end of file diff --git a/payguard-identity/src/main/resources/identity.yaml b/payguard-identity/src/main/resources/identity.yaml new file mode 100644 index 0000000..becee53 --- /dev/null +++ b/payguard-identity/src/main/resources/identity.yaml @@ -0,0 +1,9 @@ +swagger: "2.0" +info: + title: PayGuard Identity + description: PayGuard Identity + version: 1.0.0 +host: www +schemes: + - https +paths: diff --git a/payguard-identity/src/main/resources/redis/scripts/token_bucket.lua b/payguard-identity/src/main/resources/redis/scripts/token_bucket.lua new file mode 100644 index 0000000..92da912 --- /dev/null +++ b/payguard-identity/src/main/resources/redis/scripts/token_bucket.lua @@ -0,0 +1,77 @@ +-- PayGuard OTP: atomic multi-policy token bucket for a single Redis primary. +-- KEYS[i] is the bucket key for policy i. +-- ARGV consists of four values per key: +-- ARGV[4*i - 3] = policy name (non-empty string) +-- ARGV[4*i - 2] = capacity (positive integer) +-- ARGV[4*i - 1] = refillTokens (positive integer) +-- ARGV[4*i] = refillPeriodMillis (positive integer) +-- Each request costs exactly one token from each applicable bucket. +-- Result: {1, 0, ""} on success; {0, retryAfterMillis, policyName} on rejection. +-- No keys are updated on rejection. All updates are atomic on one Redis primary. + +local count = #KEYS +if count == 0 or #ARGV ~= count * 4 then + return redis.error_reply('Invalid token bucket arguments') +end + +local now = redis.call('TIME') +local now_ms = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) +local states = {} +local rejected_policy = '' +local maximum_retry_ms = 0 + +for i = 1, count do + local base = (i - 1) * 4 + local name = ARGV[base + 1] + local capacity = tonumber(ARGV[base + 2]) + local refill_tokens = tonumber(ARGV[base + 3]) + local period_ms = tonumber(ARGV[base + 4]) + + if not name or name == '' or not capacity or not refill_tokens or not period_ms + or capacity < 1 or refill_tokens < 1 or period_ms < 1 + or capacity ~= math.floor(capacity) + or refill_tokens ~= math.floor(refill_tokens) + or period_ms ~= math.floor(period_ms) then + return redis.error_reply('Invalid token bucket policy') + end + + local values = redis.call('HMGET', KEYS[i], 'tokens', 'updated_at_ms') + local tokens = capacity + if values[1] ~= false and values[2] ~= false then + local stored_tokens = tonumber(values[1]) + local updated_at_ms = tonumber(values[2]) + if not stored_tokens or not updated_at_ms then + return redis.error_reply('Corrupt token bucket state') + end + local elapsed_ms = math.max(0, now_ms - updated_at_ms) + tokens = math.min(capacity, stored_tokens + elapsed_ms * refill_tokens / period_ms) + end + + local ttl_ms = math.max(1, math.ceil(capacity * period_ms / refill_tokens)) + if ttl_ms > 9007199254740991 then + return redis.error_reply('Token bucket TTL out of range') + end + + states[i] = {tokens = tokens, ttl_ms = ttl_ms} + + if tokens < 1 then + local retry_ms = math.max(1, math.ceil((1 - tokens) * period_ms / refill_tokens)) + if retry_ms > maximum_retry_ms then + maximum_retry_ms = retry_ms + rejected_policy = name + end + end +end + +if maximum_retry_ms > 0 then + return {0, maximum_retry_ms, rejected_policy} +end + +for i = 1, count do + redis.call('HSET', KEYS[i], + 'tokens', tostring(states[i].tokens - 1), + 'updated_at_ms', tostring(now_ms)) + redis.call('PEXPIRE', KEYS[i], states[i].ttl_ms) +end + +return {1, 0, ''} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/IdentityUpgradeMigrationTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/IdentityUpgradeMigrationTest.java new file mode 100644 index 0000000..c954ad7 --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/IdentityUpgradeMigrationTest.java @@ -0,0 +1,114 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +/** Replays the committed history before upgrading populated Identity tables. */ +@Testcontainers +class IdentityUpgradeMigrationTest { + @Container + private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:18-alpine"); + + @Test + void preservesExistingRowsReferencesAndMigrationChecksums() throws SQLException { + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas("payguard") + .defaultSchema("payguard") + .target("2") + .load() + .migrate(); + try (Connection connection = + DriverManager.getConnection( + POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())) { + connection.setSchema("payguard"); + List originalChecksums = checksums(connection); + assertEquals(2, originalChecksums.size()); + try (var sql = connection.createStatement()) { + sql.executeUpdate( + "INSERT INTO identity_challenge" + + " (id,user_id,purpose,channel,verification_mode,code_hash,attempts,max_attempts,status,created_at,expires_at,retention_until)" + + " VALUES" + + " ('otp_ABCDEFGHIJKLMNOPQRSTUV','cus_ABCDEFGHIJKLMNOPQRSTUV','LOGIN','SMS','RANDOM_NUMERIC','synthetic-hash',0,3,'ACTIVE','2026-01-01Z','2026-01-01" + + " 00:05Z','2029-01-01 00:05Z')"); + sql.executeUpdate( + "INSERT INTO identity_challenge_scope VALUES ('cus_ABCDEFGHIJKLMNOPQRSTUV','LOGIN')"); + sql.executeUpdate( + "INSERT INTO identity_audit_log (id,challenge_id,user_id,action,reason,occurred_at)" + + " VALUES" + + " ('00000000-0000-0000-0000-000000000001','otp_ABCDEFGHIJKLMNOPQRSTUV','cus_ABCDEFGHIJKLMNOPQRSTUV','ISSUED','synthetic','2026-01-01Z')"); + } + Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas("payguard") + .defaultSchema("payguard") + .load() + .migrate(); + assertEquals(originalChecksums, checksums(connection)); + try (var sql = connection.createStatement(); + var rows = + sql.executeQuery( + "SELECT c.id,c.user_id,c.status::text,a.otp_id,c.retention_until FROM" + + " identity_otp c JOIN identity_audit_log a ON c.id=a.otp_id")) { + rows.next(); + assertEquals("otp_ABCDEFGHIJKLMNOPQRSTUV", rows.getString(1)); + assertEquals("cus_ABCDEFGHIJKLMNOPQRSTUV", rows.getString(2)); + assertEquals("ACTIVE", rows.getString(3)); + assertEquals(rows.getString(1), rows.getString(4)); + assertEquals( + java.time.Instant.parse("2029-01-01T00:05:00Z"), + rows.getObject(5, java.time.OffsetDateTime.class).toInstant()); + } + try (var sql = connection.createStatement()) { + var failure = + assertThrows( + SQLException.class, + () -> + sql.executeUpdate( + "DELETE FROM identity_otp WHERE id='otp_ABCDEFGHIJKLMNOPQRSTUV'")); + assertEquals("23503", failure.getSQLState()); + assertEquals( + 1, + sql.executeUpdate( + "UPDATE identity_otp SET status='INVALIDATED' WHERE" + + " id='otp_ABCDEFGHIJKLMNOPQRSTUV'")); + } + try (var sql = connection.createStatement(); + var rows = + sql.executeQuery( + "SELECT to_regclass('payguard.identity_challenge'),(SELECT count(*) FROM" + + " identity_otp_scope),(SELECT count(*) FROM identity_outbox),(SELECT" + + " count(*) FROM flyway_schema_history WHERE success AND version IS NOT NULL)")) { + rows.next(); + assertNull(rows.getString(1)); + assertEquals(1, rows.getInt(2)); + assertEquals(0, rows.getInt(3)); + assertEquals(4, rows.getInt(4)); + } + } + } + + private static List checksums(Connection connection) throws SQLException { + List values = new ArrayList<>(); + try (var sql = connection.createStatement(); + var rows = + sql.executeQuery( + "SELECT checksum FROM flyway_schema_history WHERE version IN ('1','2') ORDER BY" + + " installed_rank")) { + while (rows.next()) values.add(rows.getInt(1)); + } + return values; + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/JooqOtpRepositoryIntegrationTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/JooqOtpRepositoryIntegrationTest.java new file mode 100644 index 0000000..a1ff757 --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/JooqOtpRepositoryIntegrationTest.java @@ -0,0 +1,128 @@ +package dev.amg.payguard.identity.verification.application; + +import static dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Tables.IDENTITY_OTP; +import static dev.amg.payguard.identity.infrastructure.adapter.out.persistence.jooq.generated.Tables.IDENTITY_OUTBOX; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq.JooqOTPRepositoryAdapter; +import dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq.JsonOtpOutboxEncoder; +import dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.jooq.OtpOutboxEncoder; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.flywaydb.core.Flyway; +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.postgresql.ds.PGSimpleDataSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; +import tools.jackson.databind.json.JsonMapper; + +@Testcontainers +class JooqOtpRepositoryIntegrationTest { + private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); + private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + private static final OtpId FIRST = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + private static final OtpId SECOND = OtpId.parse("otp_ZYXWVUTSRQPONMLKJIHGFE"); + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer(DockerImageName.parse("postgres:17.11-bookworm")); + + private static DSLContext dsl; + + @BeforeAll + static void database() { + PGSimpleDataSource dataSource = new PGSimpleDataSource(); + dataSource.setURL(POSTGRES.getJdbcUrl()); + dataSource.setUser(POSTGRES.getUsername()); + dataSource.setPassword(POSTGRES.getPassword()); + Flyway.configure() + .dataSource(dataSource) + .schemas("payguard") + .defaultSchema("payguard") + .locations("classpath:db/migration/postgresql") + .load() + .migrate(); + dsl = DSL.using(dataSource, SQLDialect.POSTGRES); + } + + @Test + void replacesActiveChallengeAndPersistsLockedMutation() { + Clock clock = Clock.fixed(NOW.plusSeconds(10), ZoneOffset.UTC); + OtpOutboxEncoder encoder = new JsonOtpOutboxEncoder(JsonMapper.builder().build()); + dsl.transaction( + configuration -> { + DSLContext tx = DSL.using(configuration); + JooqOTPRepositoryAdapter repository = new JooqOTPRepositoryAdapter(tx, clock, encoder); + repository.replaceActive(issue(FIRST)); + repository.replaceActive(issue(SECOND)); + + assertEquals( + OtpStatus.INVALIDATED, + repository.findById(FIRST).orElseThrow().getSnapshot().status()); + assertEquals( + OtpStatus.ACTIVE, repository.findById(SECOND).orElseThrow().getSnapshot().status()); + assertFalse( + repository + .withLockedChallenge( + OtpId.parse("otp_0000000000000000000000"), otp -> "unexpected") + .isPresent()); + assertEquals( + "invalidated", + repository + .withLockedChallenge( + SECOND, + otp -> { + otp.invalidate(NOW.plusSeconds(20)); + return "invalidated"; + }) + .orElseThrow()); + assertEquals( + OtpStatus.INVALIDATED, + repository.findById(SECOND).orElseThrow().getSnapshot().status()); + assertEquals(4, tx.fetchCount(IDENTITY_OUTBOX)); + assertEquals(2, tx.fetchCount(IDENTITY_OTP)); + for (var payload : + tx.select(IDENTITY_OUTBOX.PAYLOAD) + .from(IDENTITY_OUTBOX) + .fetch(IDENTITY_OUTBOX.PAYLOAD)) { + assertFalse(payload.data().contains("hash:123456")); + } + }); + } + + private static OTP issue(OtpId id) { + Instant expires = NOW.plusSeconds(300); + return OTP.issue( + id, + CUSTOMER, + new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + "hash:123456", + null, + 3, + NOW, + expires, + expires.atZone(ZoneOffset.UTC).plusYears(3).toInstant(), + 0, + OtpStatus.ACTIVE, + null)); + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitDecisionTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitDecisionTest.java new file mode 100644 index 0000000..961c06a --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitDecisionTest.java @@ -0,0 +1,126 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitDecision; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RateLimitDecisionTest { + + @Test + void shouldCreateAllowedDecision() { + RateLimitDecision decision = RateLimitDecision.allowed(); + + assertTrue(decision.permitted()); + assertNull(decision.retryAfter()); + assertNull(decision.rejectedByPolicy()); + } + + @Test + void shouldCreateRejectedDecision() { + Duration retryAfter = Duration.ofSeconds(30); + + RateLimitDecision decision = RateLimitDecision.rejected(retryAfter, "otp-issue"); + + assertFalse(decision.permitted()); + assertEquals(retryAfter, decision.retryAfter()); + assertEquals("otp-issue", decision.rejectedByPolicy()); + } + + @Test + void shouldRejectAllowedDecisionWithRetryDelay() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitDecision(true, Duration.ofSeconds(10), null)); + } + + @Test + void shouldRejectAllowedDecisionWithPolicyName() { + assertThrows( + IllegalArgumentException.class, () -> new RateLimitDecision(true, null, "otp-issue")); + } + + @Test + void shouldRejectAllowedDecisionWithBothRejectionFields() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitDecision(true, Duration.ofSeconds(10), "otp-issue")); + } + + @Test + void shouldRejectMissingRetryDelay() { + assertThrows(NullPointerException.class, () -> RateLimitDecision.rejected(null, "otp-issue")); + } + + @Test + void shouldRejectZeroRetryDelay() { + assertThrows( + IllegalArgumentException.class, + () -> RateLimitDecision.rejected(Duration.ZERO, "otp-issue")); + } + + @Test + void shouldRejectNegativeRetryDelay() { + assertThrows( + IllegalArgumentException.class, + () -> RateLimitDecision.rejected(Duration.ofSeconds(-1), "otp-issue")); + } + + @Test + void shouldRejectMissingPolicyName() { + assertThrows( + NullPointerException.class, () -> RateLimitDecision.rejected(Duration.ofSeconds(10), null)); + } + + @Test + void shouldRejectEmptyPolicyName() { + assertThrows( + IllegalArgumentException.class, + () -> RateLimitDecision.rejected(Duration.ofSeconds(10), "")); + } + + @Test + void shouldRejectWhitespaceOnlyPolicyName() { + assertThrows( + IllegalArgumentException.class, + () -> RateLimitDecision.rejected(Duration.ofSeconds(10), " ")); + } + + @Test + void shouldPreserveEqualityForAllowedDecisions() { + RateLimitDecision first = RateLimitDecision.allowed(); + RateLimitDecision second = RateLimitDecision.allowed(); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + @Test + void shouldPreserveEqualityForRejectedDecisions() { + RateLimitDecision first = RateLimitDecision.rejected(Duration.ofSeconds(10), "otp-verify"); + + RateLimitDecision second = RateLimitDecision.rejected(Duration.ofSeconds(10), "otp-verify"); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + @Test + void shouldDistinguishDifferentRejectionPolicies() { + RateLimitDecision first = RateLimitDecision.rejected(Duration.ofSeconds(10), "otp-issue"); + + RateLimitDecision second = RateLimitDecision.rejected(Duration.ofSeconds(10), "otp-verify"); + + assertNotEquals(first, second); + } + + @Test + void shouldDistinguishDifferentRetryDelays() { + RateLimitDecision first = RateLimitDecision.rejected(Duration.ofSeconds(10), "otp-issue"); + + RateLimitDecision second = RateLimitDecision.rejected(Duration.ofSeconds(20), "otp-issue"); + + assertNotEquals(first, second); + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitPolicyTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitPolicyTest.java new file mode 100644 index 0000000..f5fd87e --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitPolicyTest.java @@ -0,0 +1,112 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitPolicy; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RateLimitPolicyTest { + + @Test + void shouldCreateValidPolicy() { + RateLimitPolicy policy = new RateLimitPolicy("otp-issue", 100, 25, Duration.ofSeconds(1)); + + assertEquals("otp-issue", policy.name()); + assertEquals(100, policy.capacity()); + assertEquals(25, policy.refillTokens()); + assertEquals(Duration.ofSeconds(1), policy.refillPeriod()); + } + + @Test + void shouldRejectNullName() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy(null, 100, 25, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectBlankName() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy(" ", 100, 25, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectZeroCapacity() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 0, 25, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectNegativeCapacity() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", -1, 25, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectZeroRefillTokens() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 100, 0, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectNegativeRefillTokens() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 100, -1, Duration.ofSeconds(1))); + } + + @Test + void shouldRejectNullRefillPeriod() { + assertThrows(IllegalArgumentException.class, () -> new RateLimitPolicy("otp", 100, 25, null)); + } + + @Test + void shouldRejectZeroRefillPeriod() { + assertThrows( + IllegalArgumentException.class, () -> new RateLimitPolicy("otp", 100, 25, Duration.ZERO)); + } + + @Test + void shouldRejectNegativeRefillPeriod() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 100, 25, Duration.ofSeconds(-1))); + } + + @Test + void shouldRejectSubMillisecondRefillPeriod() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 100, 25, Duration.ofNanos(1))); + } + + @Test + void shouldRejectRefillPeriodExceedingLongMilliseconds() { + assertThrows( + IllegalArgumentException.class, + () -> new RateLimitPolicy("otp", 100, 25, Duration.ofSeconds(Long.MAX_VALUE))); + } + + @Test + void shouldAcceptMinimumSupportedRefillPeriod() { + RateLimitPolicy policy = new RateLimitPolicy("otp", 100, 25, Duration.ofMillis(1)); + + assertEquals(Duration.ofMillis(1), policy.refillPeriod()); + } + + @Test + void shouldPreserveRecordValueEquality() { + RateLimitPolicy first = new RateLimitPolicy("otp", 100, 25, Duration.ofSeconds(1)); + + RateLimitPolicy second = new RateLimitPolicy("otp", 100, 25, Duration.ofSeconds(1)); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitRequestTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitRequestTest.java new file mode 100644 index 0000000..645feac --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RateLimitRequestTest.java @@ -0,0 +1,125 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitOperation; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import org.junit.jupiter.api.Test; + +class RateLimitRequestTest { + + @Test + void shouldCreateRequestWithCustomerId() { + RateLimitRequest request = + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, "192.0.2.10", OtpPurpose.LOGIN); + + assertEquals(RateLimitOperation.ISSUE, request.operation()); + assertEquals("customer-123", request.customerId()); + assertNull(request.recipientFingerprint()); + assertEquals("192.0.2.10", request.ipAddress()); + } + + @Test + void shouldCreateRequestWithRecipientFingerprint() { + RateLimitRequest request = + new RateLimitRequest( + RateLimitOperation.VERIFY, + null, + "recipient-fingerprint", + "192.0.2.10", + OtpPurpose.LOGIN); + + assertEquals("recipient-fingerprint", request.recipientFingerprint()); + } + + @Test + void shouldAcceptBothIdentityDimensions() { + RateLimitRequest request = + new RateLimitRequest( + RateLimitOperation.ISSUE, + "customer-123", + "recipient-fingerprint", + "192.0.2.10", + OtpPurpose.LOGIN); + + assertNotNull(request.customerId()); + assertNotNull(request.recipientFingerprint()); + } + + @Test + void shouldRejectMissingOperation() { + assertThrows( + NullPointerException.class, + () -> new RateLimitRequest(null, "customer-123", null, "192.0.2.10", OtpPurpose.LOGIN)); + } + + @Test + void shouldRejectMissingPurpose() { + assertThrows( + NullPointerException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, "192.0.2.10", null)); + } + + @Test + void shouldRejectMissingIpAddress() { + assertThrows( + NullPointerException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, null, OtpPurpose.LOGIN)); + } + + @Test + void shouldRejectBlankIpAddress() { + assertThrows( + IllegalArgumentException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, " ", OtpPurpose.LOGIN)); + } + + @Test + void shouldRejectBlankCustomerId() { + assertThrows( + IllegalArgumentException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, " ", null, "192.0.2.10", OtpPurpose.LOGIN)); + } + + @Test + void shouldRejectBlankRecipientFingerprint() { + assertThrows( + IllegalArgumentException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, null, "", "192.0.2.10", OtpPurpose.LOGIN)); + } + + @Test + void shouldRejectRequestWithoutIdentity() { + assertThrows( + IllegalArgumentException.class, + () -> + new RateLimitRequest( + RateLimitOperation.ISSUE, null, null, "192.0.2.10", OtpPurpose.LOGIN)); + } + + @Test + void shouldPreserveRecordEquality() { + RateLimitRequest first = + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, "192.0.2.10", OtpPurpose.LOGIN); + + RateLimitRequest second = + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-123", null, "192.0.2.10", OtpPurpose.LOGIN); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterAdapterTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterAdapterTest.java new file mode 100644 index 0000000..e0d9885 --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterAdapterTest.java @@ -0,0 +1,80 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitOperation; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitUnavailableException; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.redis.rate_limit.RedisRateLimiterAdapter; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +class RedisRateLimiterAdapterTest { + private static final RateLimitRequest REQUEST = + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-1", null, "192.0.2.10", OtpPurpose.LOGIN); + + @Test + void admitsWithinLimitAndScopesTheKeyToOtpPurpose() { + assertTrue(new ClassPathResource("redis/scripts/token_bucket.lua").exists()); + StringRedisTemplate redis = mock(StringRedisTemplate.class); + doAnswer( + invocation -> { + List keys = invocation.getArgument(1); + assertEquals( + List.of( + "identity:rate:issue:ip:192.0.2.10", + "identity:rate:issue:customer:LOGIN:customer-1"), + keys); + return List.of(1L, 0L, ""); + }) + .when(redis) + .execute(any(RedisScript.class), anyList(), any(Object[].class)); + + assertTrue( + new RedisRateLimiterAdapter(redis, 1, 2, Duration.ofMinutes(15)) + .acquire(REQUEST) + .permitted()); + } + + @Test + void rejectsWithRemainingWindow() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + doAnswer(invocation -> List.of(0L, 30_000L, "ip")) + .when(redis) + .execute(any(RedisScript.class), anyList(), any(Object[].class)); + + var decision = + new RedisRateLimiterAdapter(redis, 1, 2, Duration.ofMinutes(15)).acquire(REQUEST); + + assertFalse(decision.permitted()); + assertEquals(Duration.ofSeconds(30), decision.retryAfter()); + assertEquals("ip", decision.rejectedByPolicy()); + } + + @Test + void failsClosedWhenRedisIsUnavailable() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + doThrow(new RedisConnectionFailureException("offline")) + .when(redis) + .execute(any(RedisScript.class), anyList(), any(Object[].class)); + + assertThrows( + RateLimitUnavailableException.class, + () -> new RedisRateLimiterAdapter(redis, 1, 2, Duration.ofMinutes(15)).acquire(REQUEST)); + } +} diff --git a/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterIntegrationTest.java b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterIntegrationTest.java new file mode 100644 index 0000000..7833d5c --- /dev/null +++ b/payguard-identity/src/test/java/dev/amg/payguard/identity/verification/application/RedisRateLimiterIntegrationTest.java @@ -0,0 +1,86 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitOperation; +import dev.amg.payguard.identity.verification.application.port.rate_limit.RateLimitRequest; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.infrastructure.adapter.out.persistence.redis.rate_limit.RedisRateLimiterAdapter; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class RedisRateLimiterIntegrationTest { + @Container + private static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:8.0.3-alpine")).withExposedPorts(6379); + + private static LettuceConnectionFactory connection; + private static StringRedisTemplate redis; + + @BeforeAll + static void connect() { + connection = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379)); + connection.afterPropertiesSet(); + redis = new StringRedisTemplate(connection); + redis.afterPropertiesSet(); + } + + @AfterAll + static void disconnect() { + if (connection != null) { + connection.destroy(); + } + } + + @Test + void adapterUsesTokenBucketScriptAndDoesNotConsumeOnRejection() { + var limiter = new RedisRateLimiterAdapter(redis, 2, 2, Duration.ofMinutes(10)); + var request = + new RateLimitRequest( + RateLimitOperation.ISSUE, "customer-1", null, "192.0.2.10", OtpPurpose.LOGIN); + String ipKey = "identity:rate:issue:ip:192.0.2.10"; + String customerKey = "identity:rate:issue:customer:LOGIN:customer-1"; + + assertTrue(limiter.acquire(request).permitted()); + assertTrue(limiter.acquire(request).permitted()); + Map ipBefore = redis.opsForHash().entries(ipKey); + Map customerBefore = redis.opsForHash().entries(customerKey); + + var rejected = limiter.acquire(request); + + assertFalse(rejected.permitted()); + assertEquals("ip", rejected.rejectedByPolicy()); + assertNotNull(rejected.retryAfter()); + assertTrue(!rejected.retryAfter().isZero() && !rejected.retryAfter().isNegative()); + assertEquals(ipBefore, redis.opsForHash().entries(ipKey)); + assertEquals(customerBefore, redis.opsForHash().entries(customerKey)); + } + + @Test + void sharedIpLimitRejectsAnotherCustomerWithoutCreatingItsBucket() { + var limiter = new RedisRateLimiterAdapter(redis, 1, 1, Duration.ofMinutes(10)); + var first = + new RateLimitRequest( + RateLimitOperation.VERIFY, "customer-1", null, "192.0.2.20", OtpPurpose.LOGIN); + var second = + new RateLimitRequest( + RateLimitOperation.VERIFY, "customer-2", null, "192.0.2.20", OtpPurpose.LOGIN); + + assertTrue(limiter.acquire(first).permitted()); + assertFalse(limiter.acquire(second).permitted()); + assertEquals(Boolean.FALSE, redis.hasKey("identity:rate:verify:customer:LOGIN:customer-2")); + } +} diff --git a/payguard-identity-service/src/test/resources/application-test.yaml b/payguard-identity/src/test/resources/application-test.yaml similarity index 100% rename from payguard-identity-service/src/test/resources/application-test.yaml rename to payguard-identity/src/test/resources/application-test.yaml diff --git a/payguard-identity-service/src/test/resources/postgres-test-init.sql b/payguard-identity/src/test/resources/postgres-test-init.sql similarity index 100% rename from payguard-identity-service/src/test/resources/postgres-test-init.sql rename to payguard-identity/src/test/resources/postgres-test-init.sql diff --git a/payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql b/payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql deleted file mode 100644 index e537797..0000000 --- a/payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE loan_account (id RAW(16) PRIMARY KEY, borrower_token VARCHAR2(128) NOT NULL, collateral_id RAW(16) NOT NULL, principal NUMBER(28,8) NOT NULL, annual_rate NUMBER(20,12) NOT NULL, term_months NUMBER(5) NOT NULL, status VARCHAR2(24) NOT NULL, outstanding_principal NUMBER(28,8) NOT NULL, accrued_interest NUMBER(28,8) NOT NULL, last_accrual_date DATE NOT NULL, version NUMBER(19) DEFAULT 0 NOT NULL, retention_until TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL); -CREATE INDEX ix_loan_due ON loan_account(status, last_accrual_date); -CREATE TABLE loan_audit_log (id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, loan_id RAW(16) NOT NULL, subject_token VARCHAR2(128), actor_token VARCHAR2(128), action VARCHAR2(64) NOT NULL, reason VARCHAR2(256), occurred_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, previous_hash VARCHAR2(128), record_hash VARCHAR2(128) NOT NULL); -CREATE INDEX ix_loan_audit_subject_time ON loan_audit_log(subject_token, occurred_at); -CREATE TABLE loan_processed_event (event_id RAW(16) NOT NULL, consumer_name VARCHAR2(128) NOT NULL, processed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, PRIMARY KEY(event_id, consumer_name)); -CREATE TABLE loan_outbox (id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, event_id RAW(16) UNIQUE NOT NULL, event_type VARCHAR2(128) NOT NULL, aggregate_id VARCHAR2(128) NOT NULL, payload CLOB NOT NULL, occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, published_at TIMESTAMP WITH TIME ZONE); diff --git a/payguard-loan-service/Dockerfile b/payguard-loan/Dockerfile similarity index 100% rename from payguard-loan-service/Dockerfile rename to payguard-loan/Dockerfile diff --git a/payguard-loan-service/README.md b/payguard-loan/README.md similarity index 83% rename from payguard-loan-service/README.md rename to payguard-loan/README.md index f4400ef..60443d7 100644 --- a/payguard-loan-service/README.md +++ b/payguard-loan/README.md @@ -10,4 +10,4 @@ From the repository root with JDK 25 and Docker: ./mvnw -B -ntp -pl payguard-loan-service -am test ``` -The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Original Oracle migrations remain unchanged under `src/main/resources/db/legacy/oracle` for authorized historical migration only. Runtime database passwords do not belong in `.env`. +The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Runtime database passwords do not belong in `.env`. diff --git a/payguard-collateral-service/pom.xml b/payguard-loan/pom.xml similarity index 98% rename from payguard-collateral-service/pom.xml rename to payguard-loan/pom.xml index b294e90..0e9a130 100644 --- a/payguard-collateral-service/pom.xml +++ b/payguard-loan/pom.xml @@ -10,7 +10,7 @@ 1.0.0-SNAPSHOT - payguard-collateral-service + payguard-loan @@ -22,14 +22,23 @@ flyway-database-postgresql test + + dev.amg.payguard + payguard-shared + ${project.version} + org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-webmvc org.springframework.boot spring-boot-starter-kafka + + org.springframework.boot + spring-boot-starter-data-jpa + org.springframework.boot spring-boot-starter-flyway @@ -40,15 +49,6 @@ postgresql runtime - - dev.amg.payguard - payguard-shared - ${project.version} - - - org.springframework.boot - spring-boot-starter-webmvc - org.springframework.boot spring-boot-starter-webmvc-test diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/LoanServiceApplication.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/LoanServiceApplication.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/LoanServiceApplication.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/LoanServiceApplication.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanApplicationService.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanDataExportPort.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/application/LoanRepository.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/application/SubjectTokenVault.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/application/WalletSettlementPort.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Installment.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Installment.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Installment.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/domain/InterestAccrual.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Loan.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Loan.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Loan.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/domain/LoanStatus.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Repayment.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/domain/Repayment.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/domain/Repayment.java diff --git a/payguard-loan-service/src/main/java/dev/amg/payguard/loan/infrastructure/LoanKafkaConfiguration.java b/payguard-loan/src/main/java/dev/amg/payguard/loan/infrastructure/LoanKafkaConfiguration.java similarity index 100% rename from payguard-loan-service/src/main/java/dev/amg/payguard/loan/infrastructure/LoanKafkaConfiguration.java rename to payguard-loan/src/main/java/dev/amg/payguard/loan/infrastructure/LoanKafkaConfiguration.java diff --git a/payguard-loan-service/src/main/resources/application.yaml b/payguard-loan/src/main/resources/application.yaml similarity index 99% rename from payguard-loan-service/src/main/resources/application.yaml rename to payguard-loan/src/main/resources/application.yaml index feafdc4..39fc74f 100644 --- a/payguard-loan-service/src/main/resources/application.yaml +++ b/payguard-loan/src/main/resources/application.yaml @@ -18,14 +18,12 @@ spring: ddl-auto: validate flyway: enabled: false - payguard: vault: enabled: true address: ${VAULT_ADDR} ca-file: ${VAULT_CA_FILE} token-file: ${VAULT_TOKEN_FILE} - management: endpoint: health: @@ -38,8 +36,6 @@ management: web: exposure: include: health,info - server: shutdown: graceful - spring.lifecycle.timeout-per-shutdown-phase: 20s diff --git a/payguard-loan-service/src/main/resources/banner.txt b/payguard-loan/src/main/resources/banner.txt similarity index 100% rename from payguard-loan-service/src/main/resources/banner.txt rename to payguard-loan/src/main/resources/banner.txt diff --git a/payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql b/payguard-loan/src/main/resources/db/migration/postgresql/V1__loan_schema.sql similarity index 100% rename from payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql rename to payguard-loan/src/main/resources/db/migration/postgresql/V1__loan_schema.sql diff --git a/payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java b/payguard-loan/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java similarity index 100% rename from payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java rename to payguard-loan/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java diff --git a/payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java b/payguard-loan/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java similarity index 100% rename from payguard-loan-service/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java rename to payguard-loan/src/test/java/dev/amg/payguard/loan/domain/LoanDomainTest.java diff --git a/payguard-loan-service/src/test/resources/application-test.yaml b/payguard-loan/src/test/resources/application-test.yaml similarity index 100% rename from payguard-loan-service/src/test/resources/application-test.yaml rename to payguard-loan/src/test/resources/application-test.yaml diff --git a/payguard-loan-service/src/test/resources/postgres-test-init.sql b/payguard-loan/src/test/resources/postgres-test-init.sql similarity index 100% rename from payguard-loan-service/src/test/resources/postgres-test-init.sql rename to payguard-loan/src/test/resources/postgres-test-init.sql diff --git a/payguard-shared/src/main/java/config/time/DateTimeManager.java b/payguard-shared/src/main/java/config/time/DateTimeManager.java new file mode 100644 index 0000000..c740f3f --- /dev/null +++ b/payguard-shared/src/main/java/config/time/DateTimeManager.java @@ -0,0 +1,26 @@ +package config.time; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; + +public final class DateTimeManager { + // Standard ISO-8601 Formatter for Year-Month-Day + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // For audit trails / ticks + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public static String formatBusinessDate(LocalDate date) { + if (date == null) return null; + return date.format(DATE_FORMATTER); // Always outputs e.g., 2026-10-15 + } + + public static String formatAuditTimestamp(OffsetDateTime timestamp) { + if (timestamp == null) return null; + // Convert to UTC for institutional consistency + return timestamp.withOffsetSameInstant(ZoneOffset.UTC).format(TIMESTAMP_FORMATTER); + } +} diff --git a/payguard-shared/src/test/resources/db/legacy/identity/V1__identity_schema.sql b/payguard-shared/src/test/resources/db/legacy/identity/V1__identity_schema.sql new file mode 100644 index 0000000..e3b6381 --- /dev/null +++ b/payguard-shared/src/test/resources/db/legacy/identity/V1__identity_schema.sql @@ -0,0 +1,40 @@ +CREATE TABLE identity_challenge ( + id varchar(26) COLLATE "C" PRIMARY KEY, + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + channel varchar(32) NOT NULL, + verification_mode varchar(32) NOT NULL, + code_hash varchar(512), + device_binding_hash varchar(512), + attempts integer DEFAULT 0 NOT NULL, + max_attempts integer NOT NULL, + status varchar(20) NOT NULL, + created_at timestamptz(6) NOT NULL, + expires_at timestamptz(6) NOT NULL, + consumed_at timestamptz(6), + retention_until timestamptz(6) NOT NULL, + CONSTRAINT ck_identity_challenge_id CHECK (id ~ '^otp_[A-Za-z0-9]{22}$'), + CONSTRAINT ck_identity_customer_id CHECK (user_id ~ '^cus_[A-Za-z0-9]{22}$'), + CONSTRAINT ck_identity_attempts CHECK (max_attempts > 0 AND attempts BETWEEN 0 AND max_attempts), + CONSTRAINT ck_identity_active_attempts CHECK (status <> 'ACTIVE' OR attempts < max_attempts), + CONSTRAINT ck_identity_status CHECK (status IN ('ACTIVE', 'USED', 'LOCKED', 'EXPIRED', 'INVALIDATED')), + CONSTRAINT ck_identity_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_identity_consumption CHECK ((status = 'USED') = (consumed_at IS NOT NULL)), + CONSTRAINT ck_identity_consumption_time CHECK (consumed_at IS NULL OR (consumed_at >= created_at AND consumed_at < expires_at)), + CONSTRAINT ck_identity_code_hash CHECK (verification_mode <> 'RANDOM_NUMERIC' OR (code_hash IS NOT NULL AND length(btrim(code_hash)) > 0)), + CONSTRAINT ck_identity_binding_hash CHECK (device_binding_hash IS NULL OR length(btrim(device_binding_hash)) > 0) +); +CREATE INDEX ix_identity_challenge_active ON identity_challenge(user_id, purpose, status); +CREATE INDEX ix_identity_challenge_retention ON identity_challenge(retention_until); + +CREATE TABLE identity_audit_log ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + challenge_id varchar(26) COLLATE "C" NOT NULL REFERENCES identity_challenge(id), + user_id varchar(255) COLLATE "C" NOT NULL, + action varchar(32) NOT NULL, + reason varchar(128) NOT NULL, + source_ip varchar(64), + occurred_at timestamptz(6) NOT NULL +); +CREATE INDEX ix_identity_audit_subject_time ON identity_audit_log(user_id, occurred_at); +CREATE INDEX ix_identity_audit_challenge ON identity_audit_log(challenge_id, occurred_at); diff --git a/payguard-shared/src/test/resources/db/legacy/identity/V2__serialize_active_challenges.sql b/payguard-shared/src/test/resources/db/legacy/identity/V2__serialize_active_challenges.sql new file mode 100644 index 0000000..3fa8bf9 --- /dev/null +++ b/payguard-shared/src/test/resources/db/legacy/identity/V2__serialize_active_challenges.sql @@ -0,0 +1,12 @@ +/* Persistent scope rows serialize issuance even before a challenge exists. */ +CREATE TABLE identity_challenge_scope ( + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + PRIMARY KEY (user_id, purpose) +); +INSERT INTO identity_challenge_scope(user_id, purpose) +SELECT DISTINCT user_id, purpose FROM identity_challenge; + +/* Duplicate active rows are a migration error; this migration never discards data. */ +CREATE UNIQUE INDEX ux_identity_one_active_challenge + ON identity_challenge(user_id, purpose) WHERE status = 'ACTIVE'; diff --git a/payguard-wallet-service/README.md b/payguard-wallet-service/README.md deleted file mode 100644 index 498b101..0000000 --- a/payguard-wallet-service/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Wallet service - -Build independently from the repository root with `docker build -f payguard-wallet-service/Dockerfile -t payguard/wallet-service .`. Run with `docker run --env-file .env -e SPRING_DATASOURCE_URL="$WALLET_ORACLE_JDBC_URL" -e SPRING_DATASOURCE_USERNAME="$WALLET_ORACLE_APP_USER" -e SPRING_DATASOURCE_PASSWORD="$WALLET_ORACLE_APP_PASSWORD" -p 8081:8080 payguard/wallet-service`. diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/WalletApp.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/WalletApp.java deleted file mode 100644 index de780f3..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/WalletApp.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class WalletApp { - - public static void main(String[] args) { - SpringApplication.run(WalletApp.class, args); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java deleted file mode 100644 index 55b6947..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/FinancialAccountType.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum FinancialAccountType { - USER_WALLET, - BANK_CLEARING, - COMPANY_REVENUE, - PROMOTIONAL_EXPENSE, - SETTLEMENT_SUSPENSE -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java deleted file mode 100644 index 03fe121..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/HoldStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum HoldStatus { - PENDING, - CAPTURED, - RELEASED, - EXPIRED -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java deleted file mode 100644 index 8e5fb86..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/LedgerEntryType.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum LedgerEntryType { - DEBIT, - CREDIT -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java deleted file mode 100644 index 5f6785c..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum TransactionStatus { - INITIATED, - PENDING_SETTLEMENT, - SUCCESSFUL, - FAILED, - REVERSED -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java deleted file mode 100644 index 82e6883..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/TransactionType.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum TransactionType { - DEPOSIT, - WITHDRAWAL, - P2P_TRANSFER, - PURCHASE, - REFUND, - FEE -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java deleted file mode 100644 index aa1a64d..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/enums/WalletStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.amg.payguard.domain.enums; - -public enum WalletStatus { - ACTIVE, - SUSPENDED_DEBIT, - SUSPENDED_CREDIT, - FROZEN, - CLOSED -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java deleted file mode 100644 index 1f825cf..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCapturedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record FundsHoldCapturedEvent( - UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static FundsHoldCapturedEvent now( - HoldId holdId, WalletId walletId, Money amount, Instant now) { - return new FundsHoldCapturedEvent(UUID.randomUUID(), now, holdId, walletId, amount); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java deleted file mode 100644 index 874471f..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldCreatedEvent.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record FundsHoldCreatedEvent( - UUID eventId, - Instant occurredAt, - HoldId holdId, - WalletId walletId, - Money amount, - Instant expiresAt) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static FundsHoldCreatedEvent now( - HoldId holdId, WalletId walletId, Money amount, Instant expiresAt, Instant now) { - return new FundsHoldCreatedEvent(UUID.randomUUID(), now, holdId, walletId, amount, expiresAt); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java deleted file mode 100644 index e9a8ee3..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldExpiredEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record FundsHoldExpiredEvent( - UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) - implements DomainEvent { - public static FundsHoldExpiredEvent now( - HoldId holdId, WalletId walletId, Money amount, Instant now) { - return new FundsHoldExpiredEvent(UUID.randomUUID(), now, holdId, walletId, amount); - } - - @Override - public Instant occurredOn() { - return occurredAt; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java deleted file mode 100644 index 06a5dfd..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/FundsHoldReleasedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record FundsHoldReleasedEvent( - UUID eventId, Instant occurredAt, HoldId holdId, WalletId walletId, Money amount) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static FundsHoldReleasedEvent now( - HoldId holdId, WalletId walletId, Money amount, Instant now) { - return new FundsHoldReleasedEvent(UUID.randomUUID(), now, holdId, walletId, amount); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java deleted file mode 100644 index 3a729b6..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyDepositedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.TransactionId; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record MoneyDepositedEvent( - UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static MoneyDepositedEvent now( - TransactionId transactionId, WalletId walletId, Money amount, Instant now) { - return new MoneyDepositedEvent(UUID.randomUUID(), now, transactionId, walletId, amount); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java deleted file mode 100644 index 51bda25..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/MoneyWithdrawnEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.TransactionId; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record MoneyWithdrawnEvent( - UUID eventId, Instant occurredAt, TransactionId transactionId, WalletId walletId, Money amount) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static MoneyWithdrawnEvent now( - TransactionId transactionId, WalletId walletId, Money amount, Instant now) { - return new MoneyWithdrawnEvent(UUID.randomUUID(), now, transactionId, walletId, amount); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java deleted file mode 100644 index ef256af..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/TransferCompletedEvent.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.AccountId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.TransactionId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record TransferCompletedEvent( - UUID eventId, - Instant occurredAt, - TransactionId transactionId, - AccountId sourceAccountId, - AccountId destinationAccountId, - Money amount, - Money fee) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static TransferCompletedEvent now( - TransactionId transactionId, - AccountId source, - AccountId destination, - Money amount, - Money fee, - Instant now) { - return new TransferCompletedEvent( - UUID.randomUUID(), now, transactionId, source, destination, amount, fee); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java deleted file mode 100644 index 67d7f35..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletClosedEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record WalletClosedEvent(UUID eventId, Instant occurredAt, WalletId walletId) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static WalletClosedEvent now(WalletId walletId, Instant now) { - return new WalletClosedEvent(UUID.randomUUID(), now, walletId); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java deleted file mode 100644 index 566b317..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletCreatedEvent.java +++ /dev/null @@ -1,22 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.value_object.UserId; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.Currency; -import java.util.UUID; - -public record WalletCreatedEvent( - UUID eventId, Instant occurredAt, WalletId walletId, UserId userId, Currency currency) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static WalletCreatedEvent now( - WalletId walletId, UserId userId, Currency currency, Instant now) { - return new WalletCreatedEvent(UUID.randomUUID(), now, walletId, userId, currency); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java deleted file mode 100644 index 077e23b..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/event/WalletStatusChangedEvent.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.amg.payguard.domain.event; - -import dev.amg.payguard.domain.enums.WalletStatus; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.DomainEvent; -import java.time.Instant; -import java.util.UUID; - -public record WalletStatusChangedEvent( - UUID eventId, - Instant occurredAt, - WalletId walletId, - WalletStatus previousStatus, - WalletStatus newStatus, - String reason) - implements DomainEvent { - @Override - public Instant occurredOn() { - return occurredAt; - } - - public static WalletStatusChangedEvent now( - WalletId walletId, WalletStatus previous, WalletStatus current, String reason, Instant now) { - return new WalletStatusChangedEvent( - UUID.randomUUID(), now, walletId, previous, current, reason); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java deleted file mode 100644 index b47253d..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/CurrencyMismatchException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class CurrencyMismatchException extends DomainException { - private static final long serialVersionUID = 1L; - - public CurrencyMismatchException() { - this("Currency mismatch"); - } - - public CurrencyMismatchException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "CURRENCY_MISMATCH"; - } - - @Override - public String getTitle() { - return "Currency mismatch"; - } - - @Override - public int getHttpStatus() { - return 400; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java deleted file mode 100644 index b617fb6..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/DoubleEntryImbalanceException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class DoubleEntryImbalanceException extends DomainException { - private static final long serialVersionUID = 1L; - - public DoubleEntryImbalanceException() { - this("Double-entry transaction is imbalanced"); - } - - public DoubleEntryImbalanceException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "DOUBLE_ENTRY_IMBALANCE"; - } - - @Override - public String getTitle() { - return "Double-entry imbalance"; - } - - @Override - public int getHttpStatus() { - return 400; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java deleted file mode 100644 index 66a7879..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/HoldExpiredException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class HoldExpiredException extends DomainException { - private static final long serialVersionUID = 1L; - - public HoldExpiredException() { - this("Hold has expired"); - } - - public HoldExpiredException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "HOLD_EXPIRED"; - } - - @Override - public String getTitle() { - return "Hold expired"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java deleted file mode 100644 index 3859a96..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InsufficientFundException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class InsufficientFundException extends DomainException { - private static final long serialVersionUID = 1L; - - public InsufficientFundException() { - this("Insufficient funds"); - } - - public InsufficientFundException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "INSUFFICIENT_FUNDS"; - } - - @Override - public String getTitle() { - return "Insufficient funds"; - } - - @Override - public int getHttpStatus() { - return 422; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java deleted file mode 100644 index b8fb5b8..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidHoldStateException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class InvalidHoldStateException extends DomainException { - private static final long serialVersionUID = 1L; - - public InvalidHoldStateException() { - this("Invalid hold state"); - } - - public InvalidHoldStateException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "INVALID_HOLD_STATE"; - } - - @Override - public String getTitle() { - return "Invalid hold state"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java deleted file mode 100644 index 4b98606..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidIdempotencyKeyException.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public final class InvalidIdempotencyKeyException extends DomainException { - private static final long serialVersionUID = 1L; - - public InvalidIdempotencyKeyException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "INVALID_IDEMPOTENCY_KEY"; - } - - @Override - public String getTitle() { - return "Invalid idempotency key"; - } - - @Override - public int getHttpStatus() { - return 400; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java deleted file mode 100644 index eea1b78..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/InvalidWalletStateException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class InvalidWalletStateException extends DomainException { - private static final long serialVersionUID = 1L; - - public InvalidWalletStateException() { - this("Invalid wallet state"); - } - - public InvalidWalletStateException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "INVALID_WALLET_STATE"; - } - - @Override - public String getTitle() { - return "Invalid wallet state"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java deleted file mode 100644 index 2f6d935..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/LimitExceededException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class LimitExceededException extends DomainException { - private static final long serialVersionUID = 1L; - - public LimitExceededException() { - this("Wallet limit exceeded"); - } - - public LimitExceededException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "LIMIT_EXCEEDED"; - } - - @Override - public String getTitle() { - return "Limit exceeded"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java deleted file mode 100644 index fb52913..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/NegativeAmountNotAllowedException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class NegativeAmountNotAllowedException extends DomainException { - private static final long serialVersionUID = 1L; - - public NegativeAmountNotAllowedException() { - this("Negative amounts are not allowed"); - } - - public NegativeAmountNotAllowedException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "NEGATIVE_AMOUNT_NOT_ALLOWED"; - } - - @Override - public String getTitle() { - return "Negative amount not allowed"; - } - - @Override - public int getHttpStatus() { - return 400; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java deleted file mode 100644 index db66151..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/TransactionAlreadySettledException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class TransactionAlreadySettledException extends DomainException { - private static final long serialVersionUID = 1L; - - public TransactionAlreadySettledException() { - this("Transaction has already been settled"); - } - - public TransactionAlreadySettledException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "TRANSACTION_ALREADY_SETTLED"; - } - - @Override - public String getTitle() { - return "Transaction already settled"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java deleted file mode 100644 index f70412e..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/WalletFrozenException.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.amg.payguard.domain.exception; - -import domain.DomainException; - -public class WalletFrozenException extends DomainException { - private static final long serialVersionUID = 1L; - - public WalletFrozenException() { - this("Wallet is frozen"); - } - - public WalletFrozenException(String message) { - super(message); - } - - @Override - public String getErrorCode() { - return "WALLET_FROZEN"; - } - - @Override - public String getTitle() { - return "Wallet frozen"; - } - - @Override - public int getHttpStatus() { - return 409; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java deleted file mode 100644 index 44008df..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/exception/package-info.java +++ /dev/null @@ -1,2 +0,0 @@ -@org.springframework.modulith.NamedInterface("exceptions") -package dev.amg.payguard.domain.exception; diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java deleted file mode 100644 index 0e5b380..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Hold.java +++ /dev/null @@ -1,132 +0,0 @@ -package dev.amg.payguard.domain.model; - -import dev.amg.payguard.domain.enums.HoldStatus; -import dev.amg.payguard.domain.event.FundsHoldCapturedEvent; -import dev.amg.payguard.domain.event.FundsHoldExpiredEvent; -import dev.amg.payguard.domain.event.FundsHoldReleasedEvent; -import dev.amg.payguard.domain.exception.HoldExpiredException; -import dev.amg.payguard.domain.exception.InvalidHoldStateException; -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.AggregateRoot; -import domain.DomainEvent; -import domain.DomainValidation; -import java.time.Instant; - -public class Hold extends AggregateRoot { - private final HoldId id; - private final WalletId walletId; - private final Money amount; - private final String reason; - private final String referenceId; - private final Instant expiresAt; - private final Instant createdAt; - private HoldStatus status; - - public Hold( - HoldId id, - WalletId walletId, - Money amount, - String reason, - String referenceId, - Instant expiresAt, - Instant createdAt, - HoldStatus status) { - super(id); - this.id = DomainValidation.requireNonNull(id, "Hold id"); - this.walletId = DomainValidation.requireNonNull(walletId, "Wallet id"); - this.amount = DomainValidation.requireNonNull(amount, "Amount"); - this.reason = DomainValidation.requireText(reason, "Reason"); - this.referenceId = DomainValidation.requireText(referenceId, "Reference id"); - this.expiresAt = DomainValidation.requireNonNull(expiresAt, "Expiration time"); - this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); - this.status = DomainValidation.requireNonNull(status, "Hold status"); - - amount.assertNonNegative(); - } - - public static Hold createNew( - HoldId id, - WalletId walletId, - Money amount, - String reason, - String referenceId, - Instant expiresAt, - Instant now) { - if (expiresAt.isBefore(now)) { - throw new InvalidHoldStateException("Expiration time must be in the future"); - } - return new Hold(id, walletId, amount, reason, referenceId, expiresAt, now, HoldStatus.PENDING); - } - - public void capture(Instant now) { - assertPending(); - if (isExpired(now)) { - this.status = HoldStatus.EXPIRED; - registerEvent(FundsHoldExpiredEvent.now(this.id, this.walletId, this.amount, now)); - throw new HoldExpiredException("Cannot capture hold: Hold is already expired"); - } - this.status = HoldStatus.CAPTURED; - registerEvent(FundsHoldCapturedEvent.now(this.id, this.walletId, this.amount, now)); - } - - public void release(Instant now) { - assertPending(); - this.status = HoldStatus.RELEASED; - registerEvent(FundsHoldReleasedEvent.now(this.id, this.walletId, this.amount, now)); - } - - public void expire(Instant now) { - assertPending(); - if (!isExpired(now)) { - throw new InvalidHoldStateException( - "Cannot expire hold: Expiration time has not elapsed yet"); - } - this.status = HoldStatus.EXPIRED; - registerEvent(FundsHoldExpiredEvent.now(this.id, this.walletId, this.amount, now)); - } - - public boolean isExpired(Instant now) { - return now.isAfter(this.expiresAt); - } - - private void assertPending() { - if (this.status != HoldStatus.PENDING) { - throw new InvalidHoldStateException("Hold is not in PENDING state. Current: " + this.status); - } - } - - @Override - public HoldId getId() { - return id; - } - - public WalletId getWalletId() { - return walletId; - } - - public Money getAmount() { - return amount; - } - - public String getReason() { - return reason; - } - - public String getReferenceId() { - return referenceId; - } - - public Instant getExpiresAt() { - return expiresAt; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public HoldStatus getStatus() { - return status; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java deleted file mode 100644 index 12b02ce..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.amg.payguard.domain.model; - -import dev.amg.payguard.domain.enums.LedgerEntryType; -import dev.amg.payguard.domain.value_object.AccountId; -import dev.amg.payguard.domain.value_object.LedgerEntryId; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.TransactionId; -import domain.DomainValidation; -import java.time.Instant; - -public record LedgerEntry( - LedgerEntryId id, - TransactionId transactionId, - AccountId accountId, - Money amount, - LedgerEntryType entryType, - long sequenceNumber, - Instant timestamp) { - public LedgerEntry( - LedgerEntryId id, - TransactionId transactionId, - AccountId accountId, - Money amount, - LedgerEntryType entryType, - long sequenceNumber, - Instant timestamp) { - this.id = DomainValidation.requireNonNull(id, "Ledger entry id"); - this.transactionId = DomainValidation.requireNonNull(transactionId, "Transaction id"); - this.accountId = DomainValidation.requireNonNull(accountId, "Account id"); - this.amount = DomainValidation.requireNonNull(amount, "Amount"); - this.entryType = DomainValidation.requireNonNull(entryType, "Entry type"); - this.timestamp = DomainValidation.requireNonNull(timestamp, "Timestamp"); - - amount.assertNonNegative(); - - if (sequenceNumber < 0) { - throw new dev.amg.payguard.domain.exception.InvalidWalletStateException( - "Sequence number cannot be negative"); - } - this.sequenceNumber = sequenceNumber; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof LedgerEntry that)) return false; - return id.equals(that.id); - } - - @Override - public int hashCode() { - return id.hashCode(); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java deleted file mode 100644 index de40c0d..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Transaction.java +++ /dev/null @@ -1,155 +0,0 @@ -package dev.amg.payguard.domain.model; - -import dev.amg.payguard.domain.enums.TransactionStatus; -import dev.amg.payguard.domain.enums.TransactionType; -import dev.amg.payguard.domain.exception.InvalidWalletStateException; -import dev.amg.payguard.domain.exception.TransactionAlreadySettledException; -import dev.amg.payguard.domain.value_object.AccountId; -import dev.amg.payguard.domain.value_object.IdempotencyKey; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.TransactionId; -import domain.AggregateRoot; -import domain.DomainEvent; -import domain.DomainValidation; -import java.time.Instant; -import java.util.Optional; - -public class Transaction extends AggregateRoot { - private final TransactionId id; - private final IdempotencyKey idempotencyKey; - private final TransactionType type; - private final AccountId sourceAccountId; - private final AccountId destinationAccountId; - private final Money totalAmount; - private final Money feeAmount; - private final Instant createdAt; - private TransactionStatus status; - private String failureReason; - private Instant settledAt; - - public Transaction( - TransactionId id, - IdempotencyKey idempotencyKey, - TransactionType type, - AccountId sourceAccountId, - AccountId destinationAccountId, - Money totalAmount, - Money feeAmount, - TransactionStatus status, - String failureReason, - Instant createdAt, - Instant settledAt) { - super(id); - this.id = DomainValidation.requireNonNull(id, "Transaction id"); - this.idempotencyKey = DomainValidation.requireNonNull(idempotencyKey, "Idempotency key"); - this.type = DomainValidation.requireNonNull(type, "Transaction type"); - this.sourceAccountId = DomainValidation.requireNonNull(sourceAccountId, "Source account id"); - this.destinationAccountId = - DomainValidation.requireNonNull(destinationAccountId, "Destination account id"); - this.totalAmount = DomainValidation.requireNonNull(totalAmount, "Total amount"); - this.feeAmount = DomainValidation.requireNonNull(feeAmount, "Fee amount"); - this.status = DomainValidation.requireNonNull(status, "Transaction status"); - this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); - this.failureReason = failureReason; - this.settledAt = settledAt; - - totalAmount.assertNonNegative(); - feeAmount.assertNonNegative(); - } - - public static Transaction initiate( - TransactionId id, - IdempotencyKey idempotencyKey, - TransactionType type, - AccountId sourceAccountId, - AccountId destinationAccountId, - Money totalAmount, - Money feeAmount, - Instant now) { - return new Transaction( - id, - idempotencyKey, - type, - sourceAccountId, - destinationAccountId, - totalAmount, - feeAmount, - TransactionStatus.INITIATED, - null, - now, - null); - } - - public void markSettled(Instant now) { - if (this.status == TransactionStatus.SUCCESSFUL) { - throw new TransactionAlreadySettledException( - "Transaction has already been settled successfully: " + this.id); - } - if (this.status == TransactionStatus.FAILED) { - throw new InvalidWalletStateException("Cannot settle a failed transaction: " + this.id); - } - this.status = TransactionStatus.SUCCESSFUL; - this.settledAt = DomainValidation.requireNonNull(now, "Settlement time"); - } - - public void markFailed(String reason) { - if (this.status == TransactionStatus.SUCCESSFUL) { - throw new TransactionAlreadySettledException( - "Cannot fail an already settled transaction: " + this.id); - } - this.status = TransactionStatus.FAILED; - this.failureReason = DomainValidation.requireText(reason, "Failure reason"); - } - - public void reverse() { - if (this.status != TransactionStatus.SUCCESSFUL) { - throw new InvalidWalletStateException("Only successful transactions can be reversed"); - } - this.status = TransactionStatus.REVERSED; - } - - @Override - public TransactionId getId() { - return id; - } - - public IdempotencyKey getIdempotencyKey() { - return idempotencyKey; - } - - public TransactionType getType() { - return type; - } - - public AccountId getSourceAccountId() { - return sourceAccountId; - } - - public AccountId getDestinationAccountId() { - return destinationAccountId; - } - - public Money getTotalAmount() { - return totalAmount; - } - - public Money getFeeAmount() { - return feeAmount; - } - - public TransactionStatus getStatus() { - return status; - } - - public Optional getFailureReason() { - return Optional.ofNullable(failureReason); - } - - public Instant getCreatedAt() { - return createdAt; - } - - public Optional getSettledAt() { - return Optional.ofNullable(settledAt); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java deleted file mode 100644 index d7c23b7..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/model/Wallet.java +++ /dev/null @@ -1,206 +0,0 @@ -package dev.amg.payguard.domain.model; - -import dev.amg.payguard.domain.enums.LedgerEntryType; -import dev.amg.payguard.domain.enums.WalletStatus; -import dev.amg.payguard.domain.event.WalletClosedEvent; -import dev.amg.payguard.domain.event.WalletCreatedEvent; -import dev.amg.payguard.domain.event.WalletStatusChangedEvent; -import dev.amg.payguard.domain.exception.InsufficientFundException; -import dev.amg.payguard.domain.exception.InvalidWalletStateException; -import dev.amg.payguard.domain.exception.WalletFrozenException; -import dev.amg.payguard.domain.value_object.Money; -import dev.amg.payguard.domain.value_object.UserId; -import dev.amg.payguard.domain.value_object.WalletId; -import domain.AggregateRoot; -import domain.DomainEvent; -import domain.DomainValidation; -import java.time.Instant; -import java.util.Currency; - -public class Wallet extends AggregateRoot { - private final WalletId id; - private final UserId userId; - private final Currency currency; - private final Instant createdAt; - private WalletStatus status; - private Money bookBalance; - private Money reservedBalance; - private long version; - private Instant updatedAt; - - public Wallet( - WalletId id, - UserId userId, - Currency currency, - WalletStatus status, - Money bookBalance, - Money reservedBalance, - long version, - Instant createdAt, - Instant updatedAt) { - super(id); - this.id = DomainValidation.requireNonNull(id, "Wallet id"); - this.userId = DomainValidation.requireNonNull(userId, "User id"); - this.currency = DomainValidation.requireNonNull(currency, "Currency"); - this.status = DomainValidation.requireNonNull(status, "Wallet status"); - this.bookBalance = DomainValidation.requireNonNull(bookBalance, "Book balance"); - this.reservedBalance = DomainValidation.requireNonNull(reservedBalance, "Reserved balance"); - this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); - this.updatedAt = DomainValidation.requireNonNull(updatedAt, "Update time"); - this.version = version; - - reservedBalance.assertNonNegative(); - } - - public static Wallet create(WalletId id, UserId userId, Currency currency, Instant now) { - Wallet wallet = - new Wallet( - id, - userId, - currency, - WalletStatus.ACTIVE, - Money.zero(currency), - Money.zero(currency), - 0L, - now, - now); - wallet.registerEvent(WalletCreatedEvent.now(id, userId, currency, now)); - return wallet; - } - - public Money getAvailableBalance() { - return this.bookBalance.subtract(this.reservedBalance); - } - - public void assertCanDebit(Money amount) { - assertActive(); - if (this.status == WalletStatus.SUSPENDED_DEBIT) { - throw new WalletFrozenException("Debits are currently suspended for wallet: " + this.id); - } - amount.assertNonNegative(); - if (getAvailableBalance().isLessThan(amount)) { - throw new InsufficientFundException( - String.format( - "Insufficient funds in wallet: %s. Available: %s, Requested: %s", - this.id.value(), getAvailableBalance().amount(), amount.amount())); - } - } - - public void assertCanCredit(Money amount) { - assertActive(); - if (this.status == WalletStatus.SUSPENDED_CREDIT) { - throw new WalletFrozenException("Credits are currently suspended for wallet: " + this.id); - } - amount.assertNonNegative(); - } - - public void reserveFunds(Money amount, Instant now) { - assertCanDebit(amount); - this.reservedBalance = this.reservedBalance.add(amount); - this.updatedAt = now; - } - - public void unreserveFunds(Money amount, Instant now) { - amount.assertNonNegative(); - if (this.reservedBalance.isLessThan(amount)) { - throw new InvalidWalletStateException( - "Cannot unreserve more funds than are currently reserved"); - } - this.reservedBalance = this.reservedBalance.subtract(amount); - this.updatedAt = now; - } - - public void applyLedgerEntry(LedgerEntry entry, Instant now) { - DomainValidation.requireNonNull(entry, "Ledger entry"); - if (!entry.accountId().value().equals(this.id.value())) { - throw new InvalidWalletStateException("Ledger entry account mismatch"); - } - - if (entry.entryType() == LedgerEntryType.CREDIT) { - this.bookBalance = this.bookBalance.add(entry.amount()); - } else if (entry.entryType() == LedgerEntryType.DEBIT) { - this.bookBalance = this.bookBalance.subtract(entry.amount()); - } - this.updatedAt = now; - } - - public void suspend(String reason, Instant now) { - assertNotClosed(); - WalletStatus previous = this.status; - this.status = WalletStatus.FROZEN; - this.updatedAt = now; - registerEvent(WalletStatusChangedEvent.now(this.id, previous, this.status, reason, now)); - } - - public void activate(Instant now) { - assertNotClosed(); - WalletStatus previous = this.status; - this.status = WalletStatus.ACTIVE; - this.updatedAt = now; - registerEvent(WalletStatusChangedEvent.now(this.id, previous, this.status, "Reactivated", now)); - } - - public void close(Instant now) { - assertNotClosed(); - if (!this.bookBalance.isZero()) { - throw new InvalidWalletStateException( - "Wallet cannot be closed while book balance is non-zero: " + this.bookBalance); - } - if (!this.reservedBalance.isZero()) { - throw new InvalidWalletStateException( - "Wallet cannot be closed while funds are on hold: " + this.reservedBalance); - } - this.status = WalletStatus.CLOSED; - this.updatedAt = now; - registerEvent(WalletClosedEvent.now(this.id, now)); - } - - private void assertActive() { - if (this.status == WalletStatus.FROZEN || this.status == WalletStatus.CLOSED) { - throw new WalletFrozenException("Wallet is in inactive state: " + this.status); - } - } - - private void assertNotClosed() { - if (this.status == WalletStatus.CLOSED) { - throw new InvalidWalletStateException("Wallet is permanently closed: " + this.id); - } - } - - @Override - public WalletId getId() { - return id; - } - - public UserId getUserId() { - return userId; - } - - public Currency getCurrency() { - return currency; - } - - public WalletStatus getStatus() { - return status; - } - - public Money getBookBalance() { - return bookBalance; - } - - public Money getReservedBalance() { - return reservedBalance; - } - - public long getVersion() { - return version; - } - - public Instant getCreatedAt() { - return createdAt; - } - - public Instant getUpdatedAt() { - return updatedAt; - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java deleted file mode 100644 index 5507b6a..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/ClockPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.domain.port; - -import java.time.Instant; - -@FunctionalInterface -public interface ClockPort { - Instant now(); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java deleted file mode 100644 index 8a3e7f7..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/DomainEventPublisherPort.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.domain.port; - -import domain.DomainEvent; -import java.util.List; - -public interface DomainEventPublisherPort { - void publish(DomainEvent event); - - void publishAll(List events); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java deleted file mode 100644 index 9a05f9c..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/HoldRepositoryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.amg.payguard.domain.port; - -import dev.amg.payguard.domain.model.Hold; -import dev.amg.payguard.domain.value_object.HoldId; -import dev.amg.payguard.domain.value_object.WalletId; -import java.util.List; -import java.util.Optional; - -public interface HoldRepositoryPort { - Optional findById(HoldId id); - - List findPendingHoldsByWalletId(WalletId walletId); - - Hold save(Hold hold); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java deleted file mode 100644 index 40180da..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LedgerRepositoryPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.amg.payguard.domain.port; - -import dev.amg.payguard.domain.model.LedgerEntry; -import dev.amg.payguard.domain.value_object.AccountId; -import dev.amg.payguard.domain.value_object.Money; -import java.util.Currency; -import java.util.List; - -public interface LedgerRepositoryPort { - void saveEntries(List entries); - - Money calculateBalance(AccountId accountId, Currency currency); - - long getNextSequenceNumber(AccountId accountId); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java deleted file mode 100644 index 9ed164f..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/LoadWalletPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.amg.payguard.domain.port; - -import dev.amg.payguard.domain.model.Wallet; -import dev.amg.payguard.domain.value_object.UserId; -import dev.amg.payguard.domain.value_object.WalletId; -import java.util.Currency; -import java.util.Optional; - -public interface LoadWalletPort { - Optional findById(WalletId id); - - Optional findByIdForUpdate(WalletId id); - - boolean existsByUserIdAndCurrency(UserId userId, Currency currency); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java deleted file mode 100644 index 61fd2ec..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/SaveWalletPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.amg.payguard.domain.port; - -import dev.amg.payguard.domain.model.Wallet; - -@FunctionalInterface -public interface SaveWalletPort { - Wallet save(Wallet wallet); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java deleted file mode 100644 index 8b88430..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/port/TransactionRepositoryPort.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.amg.payguard.domain.port; - -import dev.amg.payguard.domain.model.Transaction; -import dev.amg.payguard.domain.value_object.IdempotencyKey; -import dev.amg.payguard.domain.value_object.TransactionId; -import java.util.Optional; - -public interface TransactionRepositoryPort { - Optional findById(TransactionId id); - - Optional findByIdempotencyKey(IdempotencyKey key); - - Transaction save(Transaction transaction); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java deleted file mode 100644 index d26b606..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/AccountId.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record AccountId(UUID value) implements ValueObject { - public AccountId { - DomainValidation.requireNonNull(value, "AccountId value"); - } - - public static AccountId generate() { - return new AccountId(UUID.randomUUID()); - } - - public static AccountId fromString(String uuidString) { - return new AccountId(UUID.fromString(uuidString)); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java deleted file mode 100644 index 9712943..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/HoldId.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record HoldId(UUID value) implements ValueObject { - public HoldId { - DomainValidation.requireNonNull(value, "HoldId value"); - } - - public static HoldId generate() { - return new HoldId(UUID.randomUUID()); - } - - public static HoldId fromString(String uuidString) { - return new HoldId(UUID.fromString(uuidString)); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java deleted file mode 100644 index 5ed8283..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException; -import domain.DomainValidation; -import domain.ValueObject; -import java.time.Instant; - -public record IdempotencyKey(String value, Instant createdAt) implements ValueObject { - public IdempotencyKey { - DomainValidation.requireNonNull(value, "Idempotency key value"); - DomainValidation.requireNonNull(createdAt, "CreatedAt"); - if (value.isBlank()) { - throw new InvalidIdempotencyKeyException("Idempotency key cannot be blank"); - } - } - - public static IdempotencyKey of(String value, Instant now) { - return new IdempotencyKey(value, now); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java deleted file mode 100644 index ce7e3b6..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/LedgerEntryId.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record LedgerEntryId(UUID value) implements ValueObject { - public LedgerEntryId { - DomainValidation.requireNonNull(value, "LedgerEntryId value"); - } - - public static LedgerEntryId generate() { - return new LedgerEntryId(UUID.randomUUID()); - } - - public static LedgerEntryId fromString(String uuidString) { - return new LedgerEntryId(UUID.fromString(uuidString)); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java deleted file mode 100644 index 547d786..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/Money.java +++ /dev/null @@ -1,140 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import dev.amg.payguard.domain.exception.CurrencyMismatchException; -import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException; -import domain.DomainValidation; -import domain.ValueObject; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.Currency; -import java.util.Objects; - -public record Money(BigDecimal amount, Currency currency) - implements Comparable, ValueObject { - - public Money { - DomainValidation.requireNonNull(amount, "Amount"); - DomainValidation.requireNonNull(currency, "Currency"); - - int fractionDigits = Math.max(currency.getDefaultFractionDigits(), 0); - amount = amount.setScale(fractionDigits, RoundingMode.HALF_EVEN); - } - - public static Money of(BigDecimal amount, Currency currency) { - return new Money(amount, currency); - } - - public static Money of(String amount, Currency currency) { - return new Money(new BigDecimal(amount), currency); - } - - public static Money of(long amount, Currency currency) { - return new Money(BigDecimal.valueOf(amount), currency); - } - - public static Money zero(Currency currency) { - return new Money(BigDecimal.ZERO, currency); - } - - public Money add(Money other) { - assertSameCurrency(other); - return new Money(this.amount.add(other.amount), this.currency); - } - - public Money subtract(Money other) { - assertSameCurrency(other); - return new Money(this.amount.subtract(other.amount), this.currency); - } - - public Money multiply(long factor) { - return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), this.currency); - } - - public Money multiply(BigDecimal factor) { - DomainValidation.requireNonNull(factor, "Multiplication factor"); - return new Money(this.amount.multiply(factor), this.currency); - } - - public Money abs() { - return new Money(this.amount.abs(), this.currency); - } - - public Money negate() { - return new Money(this.amount.negate(), this.currency); - } - - public void assertNonNegative() { - if (isNegative()) { - throw new NegativeAmountNotAllowedException("Amount cannot be negative: " + this.amount); - } - } - - public boolean isPositive() { - return this.amount.compareTo(BigDecimal.ZERO) > 0; - } - - public boolean isNegative() { - return this.amount.compareTo(BigDecimal.ZERO) < 0; - } - - public boolean isZero() { - return this.amount.compareTo(BigDecimal.ZERO) == 0; - } - - public boolean isGreaterThan(Money other) { - assertSameCurrency(other); - return this.compareTo(other) > 0; - } - - public boolean isGreaterThanOrEqual(Money other) { - assertSameCurrency(other); - return this.compareTo(other) >= 0; - } - - public boolean isLessThan(Money other) { - assertSameCurrency(other); - return this.compareTo(other) < 0; - } - - public boolean isLessThanOrEqual(Money other) { - assertSameCurrency(other); - return this.compareTo(other) <= 0; - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof Money money)) { - return false; - } - return amount.equals(money.amount) && currency.equals(money.currency); - } - - @Override - public int hashCode() { - return Objects.hash(amount, currency); - } - - private void assertSameCurrency(Money other) { - DomainValidation.requireNonNull(other, "Comparing Money operand"); - if (!this.currency.equals(other.currency)) { - throw new CurrencyMismatchException( - String.format( - "Currencies do not match: %s vs %s", - this.currency.getCurrencyCode(), other.currency.getCurrencyCode())); - } - } - - @Override - public int compareTo(Money o) { - assertSameCurrency(o); - return this.amount.compareTo(o.amount); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java deleted file mode 100644 index de60972..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/TransactionId.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record TransactionId(UUID value) implements ValueObject { - public TransactionId { - DomainValidation.requireNonNull(value, "TransactionId value"); - } - - public static TransactionId generate() { - return new TransactionId(UUID.randomUUID()); - } - - public static TransactionId fromString(String uuidString) { - return new TransactionId(UUID.fromString(uuidString)); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java deleted file mode 100644 index ad77d0e..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/UserId.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record UserId(UUID value) implements ValueObject { - public UserId { - DomainValidation.requireNonNull(value, "UserId value"); - } - - public static UserId generate() { - return new UserId(UUID.randomUUID()); - } - - public static UserId fromString(String uuidString) { - return new UserId(UUID.fromString(uuidString)); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java deleted file mode 100644 index 7c1d0c7..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/domain/value_object/WalletId.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.amg.payguard.domain.value_object; - -import domain.DomainValidation; -import domain.ValueObject; -import java.util.UUID; - -public record WalletId(UUID value) implements ValueObject { - public WalletId { - DomainValidation.requireNonNull(value, "WalletId value"); - } - - public static WalletId generate() { - return new WalletId(UUID.randomUUID()); - } - - public static WalletId fromString(String uuidString) { - return new WalletId(UUID.fromString(uuidString)); - } - - public AccountId toAccountId() { - return new AccountId(this.value); - } - - @Override - public boolean sameValueAs(ValueObject other) { - return equals(other); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java deleted file mode 100644 index 77a00bd..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/HandleWalletExceptions.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.TYPE, ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface HandleWalletExceptions {} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java deleted file mode 100644 index e4e02f2..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/annotation/SecureAuditOnFailure.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface SecureAuditOnFailure { - String action(); -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java deleted file mode 100644 index 0c7a0d3..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/RestControllerExceptionAspect.java +++ /dev/null @@ -1,88 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.aspect; - -import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException; -import dev.amg.payguard.domain.exception.HoldExpiredException; -import dev.amg.payguard.domain.exception.InsufficientFundException; -import dev.amg.payguard.domain.exception.InvalidHoldStateException; -import dev.amg.payguard.domain.exception.InvalidWalletStateException; -import dev.amg.payguard.domain.exception.LimitExceededException; -import dev.amg.payguard.domain.exception.TransactionAlreadySettledException; -import dev.amg.payguard.domain.exception.WalletFrozenException; -import dev.amg.payguard.infrastructure.aop.exception.model.UnifiedResponse; -import domain.DomainException; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.annotation.Pointcut; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -@Aspect -@Component -@Order(1) -public class RestControllerExceptionAspect { - private static final Logger log = LoggerFactory.getLogger(RestControllerExceptionAspect.class); - - @Pointcut( - "@annotation(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)" - + " || @within(dev.amg.payguard.infrastructure.aop.exception.annotation.HandleWalletExceptions)" - + " || within(@org.springframework.web.bind.annotation.RestController *)") - public void restControllerLayer() {} - - @Around("restControllerLayer()") - public Object interceptAndHandleExceptions(ProceedingJoinPoint joinPoint) throws Throwable { - try { - return joinPoint.proceed(); - } catch (DomainException ex) { - return mapDomainException(ex, joinPoint); - } catch (IllegalArgumentException ex) { - if (log.isWarnEnabled()) { - log.warn("Invalid argument at [{}]: {}", joinPoint.getSignature(), ex.getMessage()); - } - return build(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", ex.getMessage()); - } catch (Exception ex) { - if (log.isErrorEnabled()) { - log.error("Unhandled exception at [{}]", joinPoint.getSignature(), ex); - } - return build( - HttpStatus.INTERNAL_SERVER_ERROR, - "INTERNAL_ERROR", - "An unexpected system error occurred."); - } - } - - private ResponseEntity> mapDomainException( - DomainException ex, ProceedingJoinPoint joinPoint) { - if (ex instanceof DoubleEntryImbalanceException) { - if (log.isErrorEnabled()) { - log.error("Ledger imbalance at [{}]: {}", joinPoint.getSignature(), ex.getMessage()); - } - return build( - HttpStatus.INTERNAL_SERVER_ERROR, - "LEDGER_CORRUPTED", - "Transaction could not be balanced."); - } - if (ex instanceof WalletFrozenException) { - return build(HttpStatus.FORBIDDEN, ex.getErrorCode(), ex.getMessage()); - } - if (ex instanceof InsufficientFundException - || ex instanceof LimitExceededException - || ex instanceof HoldExpiredException) { - return build(HttpStatus.UNPROCESSABLE_CONTENT, ex.getErrorCode(), ex.getMessage()); - } - if (ex instanceof TransactionAlreadySettledException - || ex instanceof InvalidHoldStateException - || ex instanceof InvalidWalletStateException) { - return build(HttpStatus.CONFLICT, ex.getErrorCode(), ex.getMessage()); - } - return build(HttpStatus.BAD_REQUEST, ex.getErrorCode(), ex.getMessage()); - } - - private ResponseEntity> build(HttpStatus status, String code, String message) { - return ResponseEntity.status(status).body(UnifiedResponse.fail(code, message)); - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java deleted file mode 100644 index 7ff0b1d..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/ServiceExceptionEnricherAspect.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.aspect; - -import domain.DomainException; -import java.util.HashMap; -import java.util.Map; -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.AfterThrowing; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -@Aspect -@Component -@Order(2) -public class ServiceExceptionEnricherAspect { - - private static final Logger log = LoggerFactory.getLogger(ServiceExceptionEnricherAspect.class); - - @AfterThrowing(pointcut = "execution(* dev.amg.payguard.application..*(..))", throwing = "ex") - public void enrichAndLogServiceException(JoinPoint joinPoint, DomainException ex) { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - String[] paramNames = signature.getParameterNames(); - Object[] args = joinPoint.getArgs(); - - Map context = new HashMap<>(); - if (paramNames != null && args != null) { - int parameterCount = Math.min(paramNames.length, args.length); - for (int i = 0; i < parameterCount; i++) { - String parameterName = paramNames[i]; - String normalizedName = parameterName.toLowerCase(java.util.Locale.ROOT); - if (!normalizedName.contains("pin") - && !normalizedName.contains("password") - && !normalizedName.contains("token") - && !normalizedName.contains("secret")) { - context.put(parameterName, args[i]); - } - } - } - - MDC.put("failed_method", signature.getName()); - MDC.put("exception_type", ex.getClass().getSimpleName()); - try { - if (log.isWarnEnabled()) { - log.warn( - "Service execution failed: method [{}], error: '{}', context: {}", - signature, - ex.getMessage(), - context); - } - } finally { - MDC.remove("failed_method"); - MDC.remove("exception_type"); - } - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java deleted file mode 100644 index a4afbfc..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/aspect/SuspiciousActivityTrackerAspect.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.aspect; - -import dev.amg.payguard.domain.exception.InsufficientFundException; -import dev.amg.payguard.domain.exception.WalletFrozenException; -import dev.amg.payguard.infrastructure.aop.exception.annotation.SecureAuditOnFailure; -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.AfterThrowing; -import org.aspectj.lang.annotation.Aspect; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -@Aspect -@Component -public class SuspiciousActivityTrackerAspect { - - private static final Logger securityLog = LoggerFactory.getLogger("SECURITY_AUDIT"); - - @AfterThrowing(pointcut = "@annotation(secureAudit)", throwing = "ex") - public void auditSuspiciousFailure( - JoinPoint joinPoint, SecureAuditOnFailure secureAudit, Exception ex) { - String action = secureAudit.action(); - - if (ex instanceof InsufficientFundException || ex instanceof WalletFrozenException) { - if (securityLog.isErrorEnabled()) { - securityLog.error( - "Fraud alert: suspicious failure during action [{}]. Method: [{}]. Reason: {}", - action, - joinPoint.getSignature(), - ex.getMessage()); - } - - triggerSecurityCountermeasures(); - } - } - - private void triggerSecurityCountermeasures() { - // Hook for a future IP blocklist or SOC notification integration. - } -} diff --git a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java b/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java deleted file mode 100644 index e16508c..0000000 --- a/payguard-wallet-service/src/main/java/dev/amg/payguard/infrastructure/aop/exception/model/UnifiedResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.amg.payguard.infrastructure.aop.exception.model; - -import java.time.Instant; - -public record UnifiedResponse( - boolean success, String errorCode, String message, T data, Instant timestamp) { - public static UnifiedResponse ok(T data) { - return new UnifiedResponse<>(true, null, "Operation successful", data, Instant.now()); - } - - public static UnifiedResponse fail(String errorCode, String message) { - return new UnifiedResponse<>(false, errorCode, message, null, Instant.now()); - } -} diff --git a/payguard-wallet-service/src/main/resources/application.yaml b/payguard-wallet-service/src/main/resources/application.yaml deleted file mode 100644 index 963bee0..0000000 --- a/payguard-wallet-service/src/main/resources/application.yaml +++ /dev/null @@ -1,39 +0,0 @@ -spring: - application: - name: wallet-service - datasource: - url: ${SPRING_DATASOURCE_URL} - driver-class-name: org.postgresql.Driver - hikari: - maximum-pool-size: 10 - connection-timeout: 5000 - validation-timeout: 2000 - max-lifetime: 300000 - keepalive-time: 30000 - jooq: - sql-dialect: POSTGRES - -payguard: - vault: - enabled: true - address: ${VAULT_ADDR} - ca-file: ${VAULT_CA_FILE} - token-file: ${VAULT_TOKEN_FILE} - -management: - endpoint: - health: - probes: - enabled: true - group: - readiness: - include: readinessState,db,vault - endpoints: - web: - exposure: - include: health,info - -server: - shutdown: graceful - -spring.lifecycle.timeout-per-shutdown-phase: 20s diff --git a/payguard-wallet-service/src/main/resources/banner.txt b/payguard-wallet-service/src/main/resources/banner.txt deleted file mode 100644 index 978a3d7..0000000 --- a/payguard-wallet-service/src/main/resources/banner.txt +++ /dev/null @@ -1,7 +0,0 @@ -____ __ ____ ___ __ __ _______ .___________. -\ \ / \ / / / \ | | | | | ____|| | - \ \/ \/ / / ^ \ | | | | | |__ `---| |----` - \ / / /_\ \ | | | | | __| | | - \ /\ / / _____ \ | `----.| `----.| |____ | | - \__/ \__/ /__/ \__\ |_______||_______||_______| |__| - diff --git a/payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java b/payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java deleted file mode 100644 index 90a802b..0000000 --- a/payguard-wallet-service/src/test/java/dev/amg/payguard/WalletArchitectureTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.amg.payguard; - -import org.junit.jupiter.api.Test; -import org.springframework.modulith.core.ApplicationModules; - -class WalletArchitectureTests { - private final ApplicationModules modules = ApplicationModules.of(WalletApp.class); - - @Test - void verifiesApplicationModuleBoundaries() { - modules.verify(); - } -} diff --git a/payguard-wallet-service/.gitignore b/payguard-wallet/.gitignore similarity index 100% rename from payguard-wallet-service/.gitignore rename to payguard-wallet/.gitignore diff --git a/payguard-wallet-service/.mvn/wrapper/maven-wrapper.properties b/payguard-wallet/.mvn/wrapper/maven-wrapper.properties similarity index 100% rename from payguard-wallet-service/.mvn/wrapper/maven-wrapper.properties rename to payguard-wallet/.mvn/wrapper/maven-wrapper.properties diff --git a/payguard-wallet-service/Dockerfile b/payguard-wallet/Dockerfile similarity index 100% rename from payguard-wallet-service/Dockerfile rename to payguard-wallet/Dockerfile diff --git a/payguard-wallet/README.md b/payguard-wallet/README.md new file mode 100644 index 0000000..3d4c371 --- /dev/null +++ b/payguard-wallet/README.md @@ -0,0 +1,3 @@ +# Wallet service + +Wallet uses its own PostgreSQL database. Follow [the infrastructure runbook](../docs/infrastructure.md) for Vault Agent credentials, migrations and application startup. Runtime database passwords do not belong in `.env`. diff --git a/payguard-wallet-service/mvnw b/payguard-wallet/mvnw similarity index 100% rename from payguard-wallet-service/mvnw rename to payguard-wallet/mvnw diff --git a/payguard-wallet-service/pom.xml b/payguard-wallet/pom.xml similarity index 98% rename from payguard-wallet-service/pom.xml rename to payguard-wallet/pom.xml index 72d1bf0..ee73dd1 100644 --- a/payguard-wallet-service/pom.xml +++ b/payguard-wallet/pom.xml @@ -7,7 +7,7 @@ payguard-parent 1.0.0-SNAPSHOT - payguard-wallet-service + payguard-wallet Wallet Service 2.0.8 diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java index 945d44a..7dad2b7 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Hold.java @@ -12,198 +12,187 @@ import domain.AggregateRoot; import domain.DomainEvent; import domain.DomainValidation; - import java.time.Instant; import java.util.Optional; /** * A full-amount reservation, with a half-open validity interval [createdAt, expiresAt). * - *

Mutable and command-confined. Capture, release and expiry are mutually exclusive - * terminal transitions. All business validation precedes state/event mutation. A failed - * expired capture does not expire the hold implicitly; expiry is a separate successful - * command that must release wallet funds in the same database transaction. + *

Mutable and command-confined. Capture, release and expiry are mutually exclusive terminal + * transitions. All business validation precedes state/event mutation. A failed expired capture does + * not expire the hold implicitly; expiry is a separate successful command that must release wallet + * funds in the same database transaction. */ public final class Hold extends AggregateRoot { - private final WalletId walletId; - private final Money amount; - private final String reason; - private final String referenceId; - private final Instant expiresAt; - private final Instant createdAt; - private HoldStatus status; - private Instant resolvedAt; - - private Hold(HoldId id, WalletId walletId, Money amount, String reason, String referenceId, - Instant expiresAt, Instant createdAt, HoldStatus status, Instant resolvedAt) { - super(id); - this.walletId = DomainValidation.requireNonNull(walletId, "Wallet id"); - this.amount = DomainValidation.requireNonNull(amount, "Amount"); - this.reason = DomainValidation.requireText(reason, "Reason"); - this.referenceId = DomainValidation.requireText(referenceId, "Reference id"); - this.expiresAt = DomainValidation.requireNonNull(expiresAt, "Expiration time"); - this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); - this.status = DomainValidation.requireNonNull(status, "Hold status"); - amount.assertNonNegative(); - if (amount.isZero()) { - throw new InvalidHoldStateException("Hold amount must be positive"); - } - if (!expiresAt.isAfter(createdAt)) { - throw new InvalidHoldStateException("Expiration must be strictly after creation"); - } - if ((status == HoldStatus.PENDING) != (resolvedAt == null)) { - throw new InvalidHoldStateException("Resolution time is inconsistent with hold status"); - } - if (resolvedAt != null) { - validateTime(resolvedAt); - if (status == HoldStatus.CAPTURED && !resolvedAt.isBefore(expiresAt)) { - throw new InvalidHoldStateException("A captured hold must have resolved before expiry"); - } - if (status == HoldStatus.EXPIRED && resolvedAt.isBefore(expiresAt)) { - throw new InvalidHoldStateException("An expired hold cannot resolve before expiry"); - } - } - this.resolvedAt = resolvedAt; - } - - /** - * Creates a pending reservation with a strictly positive amount and future deadline. - */ - public static Hold createNew(HoldId id, WalletId walletId, Money amount, String reason, - String referenceId, Instant expiresAt, Instant now) { - return new Hold(id, walletId, amount, reason, referenceId, expiresAt, now, - HoldStatus.PENDING, null); - } - - /** - * Restores a consistent snapshot without generating domain events. - */ - public static Hold restore(HoldId id, WalletId walletId, Money amount, String reason, - String referenceId, Instant expiresAt, Instant createdAt, HoldStatus status, - Instant resolvedAt) { - return new Hold(id, walletId, amount, reason, referenceId, expiresAt, createdAt, - status, resolvedAt); - } - - /** - * Captures a live pending hold; rejected captures leave state and events unchanged. - */ - public void capture(Instant now) { - validateTime(now); - assertPending(); - if (isExpired(now)) { - throw new HoldExpiredException("Cannot capture an expired hold"); - } - FundsHoldCapturedEvent event = - FundsHoldCapturedEvent.now(getId(), walletId, amount, now); - status = HoldStatus.CAPTURED; - resolvedAt = now; - registerEvent(event); - } - - /** - * Releases any pending hold, including an overdue one, without pretending it was captured. - */ - public void release(Instant now) { - validateTime(now); - assertPending(); - FundsHoldReleasedEvent event = - FundsHoldReleasedEvent.now(getId(), walletId, amount, now); - status = HoldStatus.RELEASED; - resolvedAt = now; - registerEvent(event); - } - - /** - * Expires a pending hold at or after its deadline; requires transactional fund release. - */ - public void expire(Instant now) { - validateTime(now); - assertPending(); - if (!isExpired(now)) { - throw new InvalidHoldStateException("The hold deadline has not been reached"); - } - FundsHoldExpiredEvent event = - FundsHoldExpiredEvent.now(getId(), walletId, amount, now); - status = HoldStatus.EXPIRED; - resolvedAt = now; - registerEvent(event); - } - - /** - * Tests the deadline, not the stored lifecycle status; equality with the deadline is expired. - */ - public boolean isExpired(Instant now) { - DomainValidation.requireNonNull(now, "Evaluation time"); - return !now.isBefore(expiresAt); - } - - private void validateTime(Instant now) { - DomainValidation.requireNonNull(now, "Operation time"); - if (now.isBefore(createdAt)) { - throw new InvalidHoldStateException("Operation time precedes hold creation"); - } - } - - private void assertPending() { - if (status != HoldStatus.PENDING) { - throw new InvalidHoldStateException("Hold is not pending: " + status); - } - } - - /** - * Returns the owning wallet identity. - */ - public WalletId getWalletId() { - return walletId; - } - - /** - * Returns the immutable full reservation amount. - */ - public Money getAmount() { - return amount; - } - - /** - * Returns the business reason for the reservation. - */ - public String getReason() { - return reason; - } - - /** - * Returns the caller's business reference, not an idempotency guarantee. - */ - public String getReferenceId() { - return referenceId; - } - - /** - * Returns the exclusive capture deadline. - */ - public Instant getExpiresAt() { - return expiresAt; - } - - /** - * Returns the immutable creation timestamp. - */ - public Instant getCreatedAt() { - return createdAt; - } - - /** - * Returns the stored lifecycle state, which may be pending after the deadline. - */ - public HoldStatus getStatus() { - return status; - } - - /** - * Returns the terminal transition time, absent for pending holds. - */ - public Optional getResolvedAt() { - return Optional.ofNullable(resolvedAt); - } + private final WalletId walletId; + private final Money amount; + private final String reason; + private final String referenceId; + private final Instant expiresAt; + private final Instant createdAt; + private HoldStatus status; + private Instant resolvedAt; + + private Hold( + HoldId id, + WalletId walletId, + Money amount, + String reason, + String referenceId, + Instant expiresAt, + Instant createdAt, + HoldStatus status, + Instant resolvedAt) { + super(id); + this.walletId = DomainValidation.requireNonNull(walletId, "Wallet id"); + this.amount = DomainValidation.requireNonNull(amount, "Amount"); + this.reason = DomainValidation.requireText(reason, "Reason"); + this.referenceId = DomainValidation.requireText(referenceId, "Reference id"); + this.expiresAt = DomainValidation.requireNonNull(expiresAt, "Expiration time"); + this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); + this.status = DomainValidation.requireNonNull(status, "Hold status"); + amount.assertNonNegative(); + if (amount.isZero()) { + throw new InvalidHoldStateException("Hold amount must be positive"); + } + if (!expiresAt.isAfter(createdAt)) { + throw new InvalidHoldStateException("Expiration must be strictly after creation"); + } + if ((status == HoldStatus.PENDING) != (resolvedAt == null)) { + throw new InvalidHoldStateException("Resolution time is inconsistent with hold status"); + } + if (resolvedAt != null) { + validateTime(resolvedAt); + if (status == HoldStatus.CAPTURED && !resolvedAt.isBefore(expiresAt)) { + throw new InvalidHoldStateException("A captured hold must have resolved before expiry"); + } + if (status == HoldStatus.EXPIRED && resolvedAt.isBefore(expiresAt)) { + throw new InvalidHoldStateException("An expired hold cannot resolve before expiry"); + } + } + this.resolvedAt = resolvedAt; + } + + /** Creates a pending reservation with a strictly positive amount and future deadline. */ + public static Hold createNew( + HoldId id, + WalletId walletId, + Money amount, + String reason, + String referenceId, + Instant expiresAt, + Instant now) { + return new Hold( + id, walletId, amount, reason, referenceId, expiresAt, now, HoldStatus.PENDING, null); + } + + /** Restores a consistent snapshot without generating domain events. */ + public static Hold restore( + HoldId id, + WalletId walletId, + Money amount, + String reason, + String referenceId, + Instant expiresAt, + Instant createdAt, + HoldStatus status, + Instant resolvedAt) { + return new Hold( + id, walletId, amount, reason, referenceId, expiresAt, createdAt, status, resolvedAt); + } + + /** Captures a live pending hold; rejected captures leave state and events unchanged. */ + public void capture(Instant now) { + validateTime(now); + assertPending(); + if (isExpired(now)) { + throw new HoldExpiredException("Cannot capture an expired hold"); + } + FundsHoldCapturedEvent event = FundsHoldCapturedEvent.now(getId(), walletId, amount, now); + status = HoldStatus.CAPTURED; + resolvedAt = now; + registerEvent(event); + } + + /** Releases any pending hold, including an overdue one, without pretending it was captured. */ + public void release(Instant now) { + validateTime(now); + assertPending(); + FundsHoldReleasedEvent event = FundsHoldReleasedEvent.now(getId(), walletId, amount, now); + status = HoldStatus.RELEASED; + resolvedAt = now; + registerEvent(event); + } + + /** Expires a pending hold at or after its deadline; requires transactional fund release. */ + public void expire(Instant now) { + validateTime(now); + assertPending(); + if (!isExpired(now)) { + throw new InvalidHoldStateException("The hold deadline has not been reached"); + } + FundsHoldExpiredEvent event = FundsHoldExpiredEvent.now(getId(), walletId, amount, now); + status = HoldStatus.EXPIRED; + resolvedAt = now; + registerEvent(event); + } + + /** Tests the deadline, not the stored lifecycle status; equality with the deadline is expired. */ + public boolean isExpired(Instant now) { + DomainValidation.requireNonNull(now, "Evaluation time"); + return !now.isBefore(expiresAt); + } + + private void validateTime(Instant now) { + DomainValidation.requireNonNull(now, "Operation time"); + if (now.isBefore(createdAt)) { + throw new InvalidHoldStateException("Operation time precedes hold creation"); + } + } + + private void assertPending() { + if (status != HoldStatus.PENDING) { + throw new InvalidHoldStateException("Hold is not pending: " + status); + } + } + + /** Returns the owning wallet identity. */ + public WalletId getWalletId() { + return walletId; + } + + /** Returns the immutable full reservation amount. */ + public Money getAmount() { + return amount; + } + + /** Returns the business reason for the reservation. */ + public String getReason() { + return reason; + } + + /** Returns the caller's business reference, not an idempotency guarantee. */ + public String getReferenceId() { + return referenceId; + } + + /** Returns the exclusive capture deadline. */ + public Instant getExpiresAt() { + return expiresAt; + } + + /** Returns the immutable creation timestamp. */ + public Instant getCreatedAt() { + return createdAt; + } + + /** Returns the stored lifecycle state, which may be pending after the deadline. */ + public HoldStatus getStatus() { + return status; + } + + /** Returns the terminal transition time, absent for pending holds. */ + public Optional getResolvedAt() { + return Optional.ofNullable(resolvedAt); + } } diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java index a303fba..9119c91 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/LedgerEntry.java @@ -7,66 +7,59 @@ import dev.amg.payguard.domain.value_object.Money; import dev.amg.payguard.domain.value_object.TransactionId; import domain.DomainValidation; - import java.time.Instant; /** * An immutable accounting entry, with entity equality by entry identifier. * - *

The amount is strictly positive; direction is expressed by {@code entryType}. - * Sequence numbers are non-negative and ordered within an account. Allocation, - * uniqueness, immutability in storage, and tenant/account ownership belong to persistence. - * A single entry cannot establish double-entry balance; {@link Transaction} validates a batch. + *

The amount is strictly positive; direction is expressed by {@code entryType}. Sequence numbers + * are non-negative and ordered within an account. Allocation, uniqueness, immutability in storage, + * and tenant/account ownership belong to persistence. A single entry cannot establish double-entry + * balance; {@link Transaction} validates a batch. * - * @param id identity of this entry - * @param transactionId transaction owning this entry - * @param accountId account affected by this entry - * @param amount exact, strictly positive posting magnitude - * @param entryType debit or credit direction + * @param id identity of this entry + * @param transactionId transaction owning this entry + * @param accountId account affected by this entry + * @param amount exact, strictly positive posting magnitude + * @param entryType debit or credit direction * @param sequenceNumber non-negative account posting sequence - * @param timestamp posting timestamp + * @param timestamp posting timestamp */ public record LedgerEntry( - LedgerEntryId id, - TransactionId transactionId, - AccountId accountId, - Money amount, - LedgerEntryType entryType, - long sequenceNumber, - Instant timestamp) { + LedgerEntryId id, + TransactionId transactionId, + AccountId accountId, + Money amount, + LedgerEntryType entryType, + long sequenceNumber, + Instant timestamp) { - /** - * Validates all entry-local invariants without changing the monetary amount. - */ - public LedgerEntry { - DomainValidation.requireNonNull(id, "Ledger entry id"); - DomainValidation.requireNonNull(transactionId, "Transaction id"); - DomainValidation.requireNonNull(accountId, "Account id"); - DomainValidation.requireNonNull(amount, "Amount"); - DomainValidation.requireNonNull(entryType, "Entry type"); - DomainValidation.requireNonNull(timestamp, "Timestamp"); - amount.assertNonNegative(); - if (amount.isZero()) { - throw new InvalidWalletStateException("Ledger entry amount must be positive"); - } - if (sequenceNumber < 0) { - throw new InvalidWalletStateException("Sequence number cannot be negative"); - } + /** Validates all entry-local invariants without changing the monetary amount. */ + public LedgerEntry { + DomainValidation.requireNonNull(id, "Ledger entry id"); + DomainValidation.requireNonNull(transactionId, "Transaction id"); + DomainValidation.requireNonNull(accountId, "Account id"); + DomainValidation.requireNonNull(amount, "Amount"); + DomainValidation.requireNonNull(entryType, "Entry type"); + DomainValidation.requireNonNull(timestamp, "Timestamp"); + amount.assertNonNegative(); + if (amount.isZero()) { + throw new InvalidWalletStateException("Ledger entry amount must be positive"); } - - /** - * Compares entry identity, not posting content; never use this to validate retry payloads. - */ - @Override - public boolean equals(Object other) { - return this == other || other instanceof LedgerEntry entry && id.equals(entry.id); + if (sequenceNumber < 0) { + throw new InvalidWalletStateException("Sequence number cannot be negative"); } + } - /** - * Returns a stable hash derived only from the immutable identity. - */ - @Override - public int hashCode() { - return id.hashCode(); - } + /** Compares entry identity, not posting content; never use this to validate retry payloads. */ + @Override + public boolean equals(Object other) { + return this == other || other instanceof LedgerEntry entry && id.equals(entry.id); + } + + /** Returns a stable hash derived only from the immutable identity. */ + @Override + public int hashCode() { + return id.hashCode(); + } } diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java index a1afe1e..495b618 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Transaction.java @@ -7,11 +7,14 @@ import dev.amg.payguard.domain.exception.DoubleEntryImbalanceException; import dev.amg.payguard.domain.exception.InvalidWalletStateException; import dev.amg.payguard.domain.exception.TransactionAlreadySettledException; -import dev.amg.payguard.domain.value_object.*; +import dev.amg.payguard.domain.value_object.AccountId; +import dev.amg.payguard.domain.value_object.IdempotencyKey; +import dev.amg.payguard.domain.value_object.LedgerEntryId; +import dev.amg.payguard.domain.value_object.Money; +import dev.amg.payguard.domain.value_object.TransactionId; import domain.AggregateRoot; import domain.DomainEvent; import domain.DomainValidation; - import java.time.Instant; import java.util.HashSet; import java.util.List; @@ -21,351 +24,375 @@ /** * A single-currency transfer with an optional additional fee paid by the source. * - *

Explicit policy: {@code totalAmount} is the principal delivered to the destination; - * the source is debited {@code totalAmount + feeAmount}. Separate fee accounts receive - * fee credits. Fees may be split across accounts. FX, netted fees and partial reversals - * are not supported by this model. + *

Explicit policy: {@code totalAmount} is the principal delivered to the destination; the source + * is debited {@code totalAmount + feeAmount}. Separate fee accounts receive fee credits. Fees may + * be split across accounts. FX, netted fees and partial reversals are not supported by this model. * - *

Mutable and command-confined. Settlement validates postings but does not write them. - * The application must atomically persist transaction state, all entries, affected wallet - * balances, idempotency result and outbox in the same local database transaction. + *

Mutable and command-confined. Settlement validates postings but does not write them. The + * application must atomically persist transaction state, all entries, affected wallet balances, + * idempotency result and outbox in the same local database transaction. */ public final class Transaction extends AggregateRoot { - private final IdempotencyKey idempotencyKey; - private final TransactionType type; - private final AccountId sourceAccountId; - private final AccountId destinationAccountId; - private final Money totalAmount; - private final Money feeAmount; - private final Instant createdAt; - private TransactionStatus status; - private String failureReason; - private Instant settledAt; - private TransactionId reversalTransactionId; - private Instant reversedAt; + private static final int MIN_POSTING_ENTRIES = 2; - private Transaction(TransactionId id, IdempotencyKey idempotencyKey, TransactionType type, - AccountId sourceAccountId, AccountId destinationAccountId, Money totalAmount, Money feeAmount, - TransactionStatus status, String failureReason, Instant createdAt, Instant settledAt, - TransactionId reversalTransactionId, Instant reversedAt) { - super(id); - this.idempotencyKey = DomainValidation.requireNonNull(idempotencyKey, "Idempotency key"); - this.type = DomainValidation.requireNonNull(type, "Transaction type"); - this.sourceAccountId = DomainValidation.requireNonNull(sourceAccountId, "Source account id"); - this.destinationAccountId = - DomainValidation.requireNonNull(destinationAccountId, "Destination account id"); - this.totalAmount = DomainValidation.requireNonNull(totalAmount, "Total amount"); - this.feeAmount = DomainValidation.requireNonNull(feeAmount, "Fee amount"); - this.status = DomainValidation.requireNonNull(status, "Transaction status"); - this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); - totalAmount.assertNonNegative(); - feeAmount.assertNonNegative(); - if (totalAmount.isZero()) { - throw new InvalidWalletStateException("Transaction principal must be positive"); - } - if (sourceAccountId.equals(destinationAccountId)) { - throw new InvalidWalletStateException("Source and destination accounts must differ"); - } - if (!totalAmount.currency().equals(feeAmount.currency())) { - throw new CurrencyMismatchException("Principal and fee currencies must match"); - } - validateSnapshot(status, failureReason, settledAt, reversalTransactionId, reversedAt); - this.failureReason = failureReason; - this.settledAt = settledAt; - this.reversalTransactionId = reversalTransactionId; - this.reversedAt = reversedAt; - } + private final IdempotencyKey idempotencyKey; + private final TransactionType type; + private final AccountId sourceAccountId; + private final AccountId destinationAccountId; + private final Money totalAmount; + private final Money feeAmount; + private final Instant createdAt; + private TransactionStatus status; + private String failureReason; + private Instant settledAt; + private TransactionId reversalTransactionId; + private Instant reversedAt; - /** - * Creates a new, unsettled transaction without performing IO or posting funds. - */ - public static Transaction initiate(TransactionId id, IdempotencyKey idempotencyKey, - TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId, - Money totalAmount, Money feeAmount, Instant now) { - return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId, - totalAmount, feeAmount, TransactionStatus.INITIATED, null, now, null, null, null); + private Transaction( + TransactionId id, + IdempotencyKey idempotencyKey, + TransactionType type, + AccountId sourceAccountId, + AccountId destinationAccountId, + Money totalAmount, + Money feeAmount, + TransactionStatus status, + String failureReason, + Instant createdAt, + Instant settledAt, + TransactionId reversalTransactionId, + Instant reversedAt) { + super(id); + this.idempotencyKey = DomainValidation.requireNonNull(idempotencyKey, "Idempotency key"); + this.type = DomainValidation.requireNonNull(type, "Transaction type"); + this.sourceAccountId = DomainValidation.requireNonNull(sourceAccountId, "Source account id"); + this.destinationAccountId = + DomainValidation.requireNonNull(destinationAccountId, "Destination account id"); + this.totalAmount = DomainValidation.requireNonNull(totalAmount, "Total amount"); + this.feeAmount = DomainValidation.requireNonNull(feeAmount, "Fee amount"); + this.status = DomainValidation.requireNonNull(status, "Transaction status"); + this.createdAt = DomainValidation.requireNonNull(createdAt, "Creation time"); + totalAmount.assertNonNegative(); + feeAmount.assertNonNegative(); + if (totalAmount.isZero()) { + throw new InvalidWalletStateException("Transaction principal must be positive"); } - - /** - * Restores a consistent persisted snapshot without replaying any state transitions. - */ - public static Transaction restore(TransactionId id, IdempotencyKey idempotencyKey, - TransactionType type, AccountId sourceAccountId, AccountId destinationAccountId, - Money totalAmount, Money feeAmount, TransactionStatus status, String failureReason, - Instant createdAt, Instant settledAt, TransactionId reversalTransactionId, Instant reversedAt) { - return new Transaction(id, idempotencyKey, type, sourceAccountId, destinationAccountId, - totalAmount, feeAmount, status, failureReason, createdAt, settledAt, - reversalTransactionId, reversedAt); + if (sourceAccountId.equals(destinationAccountId)) { + throw new InvalidWalletStateException("Source and destination accounts must differ"); } - - /** - * Moves an initiated transaction into pending settlement exactly once. - */ - public void markPendingSettlement() { - if (status != TransactionStatus.INITIATED) { - throw new InvalidWalletStateException("Only an initiated transaction can become pending"); - } - status = TransactionStatus.PENDING_SETTLEMENT; + if (!totalAmount.currency().equals(feeAmount.currency())) { + throw new CurrencyMismatchException("Principal and fee currencies must match"); } + validateSnapshot(status, failureReason, settledAt, reversalTransactionId, reversedAt); + this.failureReason = failureReason; + this.settledAt = settledAt; + this.reversalTransactionId = reversalTransactionId; + this.reversedAt = reversedAt; + } + + /** Creates a new, unsettled transaction without performing IO or posting funds. */ + public static Transaction initiate( + TransactionId id, + IdempotencyKey idempotencyKey, + TransactionType type, + AccountId sourceAccountId, + AccountId destinationAccountId, + Money totalAmount, + Money feeAmount, + Instant now) { + return new Transaction( + id, + idempotencyKey, + type, + sourceAccountId, + destinationAccountId, + totalAmount, + feeAmount, + TransactionStatus.INITIATED, + null, + now, + null, + null, + null); + } - /** - * Validates the complete posting batch and marks an open transaction successful. - * All validation happens before mutation. This is not a database commit. - */ - public void markSettled(List entries, Instant now) { - assertOpen(); - validateTime(now); - validatePosting(entries, now); - status = TransactionStatus.SUCCESSFUL; - settledAt = now; + /** Restores a consistent persisted snapshot without replaying any state transitions. */ + public static Transaction restore( + TransactionId id, + IdempotencyKey idempotencyKey, + TransactionType type, + AccountId sourceAccountId, + AccountId destinationAccountId, + Money totalAmount, + Money feeAmount, + TransactionStatus status, + String failureReason, + Instant createdAt, + Instant settledAt, + TransactionId reversalTransactionId, + Instant reversedAt) { + return new Transaction( + id, + idempotencyKey, + type, + sourceAccountId, + destinationAccountId, + totalAmount, + feeAmount, + status, + failureReason, + createdAt, + settledAt, + reversalTransactionId, + reversedAt); + } + + /** Moves an initiated transaction into pending settlement exactly once. */ + public void markPendingSettlement() { + if (status != TransactionStatus.INITIATED) { + throw new InvalidWalletStateException("Only an initiated transaction can become pending"); } + status = TransactionStatus.PENDING_SETTLEMENT; + } - /** - * Checks entry identity, account sequences, currency, time, principal/fee allocation - * and equal debit/credit totals without changing transaction state. - */ - public void validatePosting(List entries, Instant now) { - DomainValidation.requireNonNull(entries, "Ledger entries"); - validateTime(now); - for (LedgerEntry entry : entries) { - DomainValidation.requireNonNull(entry, "Ledger entry"); - } - List posting = List.copyOf(entries); - if (posting.size() < 2) { - throw new DoubleEntryImbalanceException("A posting requires at least two entries"); - } - Set entryIds = new HashSet<>(); - Set accountSequences = new HashSet<>(); - Money debits = Money.zero(totalAmount.currency()); - Money credits = Money.zero(totalAmount.currency()); - Money sourceDebits = Money.zero(totalAmount.currency()); - Money destinationCredits = Money.zero(totalAmount.currency()); - Money feeCredits = Money.zero(totalAmount.currency()); + /** + * Validates the complete posting batch and marks an open transaction successful. All validation + * happens before mutation. This is not a database commit. + */ + public void markSettled(List entries, Instant now) { + assertOpen(); + validateTime(now); + validatePosting(entries, now); + status = TransactionStatus.SUCCESSFUL; + settledAt = now; + } + + /** + * Checks entry identity, account sequences, currency, time, principal/fee allocation and equal + * debit/credit totals without changing transaction state. + */ + public void validatePosting(List entries, Instant now) { + DomainValidation.requireNonNull(entries, "Ledger entries"); + validateTime(now); + for (LedgerEntry entry : entries) { + DomainValidation.requireNonNull(entry, "Ledger entry"); + } + List posting = List.copyOf(entries); + if (posting.size() < MIN_POSTING_ENTRIES) { + throw new DoubleEntryImbalanceException("A posting requires at least two entries"); + } + Set entryIds = new HashSet<>(); + Set accountSequences = new HashSet<>(); + Money debits = Money.zero(totalAmount.currency()); + Money credits = Money.zero(totalAmount.currency()); + Money sourceDebits = Money.zero(totalAmount.currency()); + Money destinationCredits = Money.zero(totalAmount.currency()); + Money feeCredits = Money.zero(totalAmount.currency()); - for (LedgerEntry entry : posting) { - if (!getId().equals(entry.transactionId())) { - throw new InvalidWalletStateException("Entry belongs to another transaction"); - } - if (!entryIds.add(entry.id())) { - throw new InvalidWalletStateException("Duplicate ledger entry identity in batch"); - } - if (!accountSequences.add(new AccountSequence(entry.accountId(), entry.sequenceNumber()))) { - throw new InvalidWalletStateException("Duplicate account sequence in batch"); - } - if (!totalAmount.currency().equals(entry.amount().currency())) { - throw new CurrencyMismatchException("Posting currency must match transaction currency"); - } - if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) { - throw new InvalidWalletStateException("Posting timestamp is outside transaction interval"); - } - if (entry.entryType() == LedgerEntryType.DEBIT) { - if (!entry.accountId().equals(sourceAccountId)) { - throw new InvalidWalletStateException("Only the source account may be debited"); - } - debits = debits.add(entry.amount()); - sourceDebits = sourceDebits.add(entry.amount()); - } else { - if (entry.accountId().equals(sourceAccountId)) { - throw new InvalidWalletStateException("Source account cannot receive credit in this posting"); - } - credits = credits.add(entry.amount()); - if (entry.accountId().equals(destinationAccountId)) { - destinationCredits = destinationCredits.add(entry.amount()); - } else { - feeCredits = feeCredits.add(entry.amount()); - } - } + for (LedgerEntry entry : posting) { + if (!getId().equals(entry.transactionId())) { + throw new InvalidWalletStateException("Entry belongs to another transaction"); + } + if (!entryIds.add(entry.id())) { + throw new InvalidWalletStateException("Duplicate ledger entry identity in batch"); + } + if (!accountSequences.add(new AccountSequence(entry.accountId(), entry.sequenceNumber()))) { + throw new InvalidWalletStateException("Duplicate account sequence in batch"); + } + if (!totalAmount.currency().equals(entry.amount().currency())) { + throw new CurrencyMismatchException("Posting currency must match transaction currency"); + } + if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) { + throw new InvalidWalletStateException("Posting timestamp is outside transaction interval"); + } + if (entry.entryType() == LedgerEntryType.DEBIT) { + if (!entry.accountId().equals(sourceAccountId)) { + throw new InvalidWalletStateException("Only the source account may be debited"); } - if (!debits.equals(credits)) { - throw new DoubleEntryImbalanceException("Debit and credit totals differ"); + debits = debits.add(entry.amount()); + sourceDebits = sourceDebits.add(entry.amount()); + } else { + if (entry.accountId().equals(sourceAccountId)) { + throw new InvalidWalletStateException( + "Source account cannot receive credit in this posting"); } - if (!sourceDebits.equals(totalAmount.add(feeAmount)) - || !destinationCredits.equals(totalAmount) - || !feeCredits.equals(feeAmount)) { - throw new InvalidWalletStateException("Posting does not match principal and fee allocation"); + credits = credits.add(entry.amount()); + if (entry.accountId().equals(destinationAccountId)) { + destinationCredits = destinationCredits.add(entry.amount()); + } else { + feeCredits = feeCredits.add(entry.amount()); } + } + } + if (!debits.equals(credits)) { + throw new DoubleEntryImbalanceException("Debit and credit totals differ"); + } + if (!sourceDebits.equals(totalAmount.add(feeAmount)) + || !destinationCredits.equals(totalAmount) + || !feeCredits.equals(feeAmount)) { + throw new InvalidWalletStateException("Posting does not match principal and fee allocation"); } + } - /** - * Fails only an open transaction; invalid reasons do not alter state. - */ - public void markFailed(String reason) { - assertOpen(); - String validatedReason = DomainValidation.requireText(reason, "Failure reason"); - status = TransactionStatus.FAILED; - failureReason = validatedReason; + /** Fails only an open transaction; invalid reasons do not alter state. */ + public void markFailed(String reason) { + assertOpen(); + String validatedReason = DomainValidation.requireText(reason, "Failure reason"); + status = TransactionStatus.FAILED; + failureReason = validatedReason; + } + + /** + * Links a successful, full, zero-fee compensating REFUND transaction. + * + *

This deliberately refuses fee-bearing reversals until a fee-refund posting policy is + * defined. The original entries are never changed. Persist the reversal and this link atomically, + * with a unique original/reversal relationship and tenant checks. + */ + public void markReversed(Transaction reversal) { + DomainValidation.requireNonNull(reversal, "Reversal transaction"); + if (status != TransactionStatus.SUCCESSFUL) { + throw new InvalidWalletStateException("Only a successful transaction can be reversed"); + } + if (getId().equals(reversal.getId())) { + throw new InvalidWalletStateException("A transaction cannot reverse itself"); + } + if (reversal.status != TransactionStatus.SUCCESSFUL + || reversal.type != TransactionType.REFUND) { + throw new InvalidWalletStateException("Reversal must be a successful refund transaction"); + } + if (!feeAmount.isZero() || !reversal.feeAmount.isZero()) { + throw new InvalidWalletStateException( + "Fee-bearing reversals require an explicit refund policy"); + } + if (!sourceAccountId.equals(reversal.destinationAccountId) + || !destinationAccountId.equals(reversal.sourceAccountId) + || !totalAmount.equals(reversal.totalAmount)) { + throw new InvalidWalletStateException("Reversal must return the full original principal"); } + if (reversal.createdAt.isBefore(settledAt) || reversal.settledAt.isBefore(settledAt)) { + throw new InvalidWalletStateException("Reversal cannot precede original settlement"); + } + reversalTransactionId = reversal.getId(); + reversedAt = reversal.settledAt; + status = TransactionStatus.REVERSED; + } - /** - * Links a successful, full, zero-fee compensating REFUND transaction. - * - *

This deliberately refuses fee-bearing reversals until a fee-refund posting policy - * is defined. The original entries are never changed. Persist the reversal and this - * link atomically, with a unique original/reversal relationship and tenant checks. - */ - public void markReversed(Transaction reversal) { - DomainValidation.requireNonNull(reversal, "Reversal transaction"); - if (status != TransactionStatus.SUCCESSFUL) { - throw new InvalidWalletStateException("Only a successful transaction can be reversed"); - } - if (reversal == this || getId().equals(reversal.getId())) { - throw new InvalidWalletStateException("A transaction cannot reverse itself"); + private void validateSnapshot( + TransactionStatus status, + String reason, + Instant settlement, + TransactionId reversalId, + Instant reversalTime) { + if (settlement != null) { + validateTime(settlement); + } + switch (status) { + case INITIATED, PENDING_SETTLEMENT -> { + if (reason != null || settlement != null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Open transaction contains terminal metadata"); } - if (reversal.status != TransactionStatus.SUCCESSFUL || reversal.type != TransactionType.REFUND) { - throw new InvalidWalletStateException("Reversal must be a successful refund transaction"); + } + case FAILED -> { + DomainValidation.requireText(reason, "Failure reason"); + if (settlement != null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Failed transaction contains settlement metadata"); } - if (!feeAmount.isZero() || !reversal.feeAmount.isZero()) { - throw new InvalidWalletStateException("Fee-bearing reversals require an explicit refund policy"); + } + case SUCCESSFUL -> { + if (reason != null || settlement == null || reversalId != null || reversalTime != null) { + throw new InvalidWalletStateException("Successful transaction metadata is inconsistent"); } - if (!sourceAccountId.equals(reversal.destinationAccountId) - || !destinationAccountId.equals(reversal.sourceAccountId) - || !totalAmount.equals(reversal.totalAmount)) { - throw new InvalidWalletStateException("Reversal must return the full original principal"); + } + case REVERSED -> { + if (reason != null || settlement == null || reversalId == null || reversalTime == null) { + throw new InvalidWalletStateException("Reversed transaction metadata is incomplete"); } - if (reversal.createdAt.isBefore(settledAt) || reversal.settledAt.isBefore(settledAt)) { - throw new InvalidWalletStateException("Reversal cannot precede original settlement"); + if (getId().equals(reversalId) + || reversalTime.isBefore(settlement) + || !feeAmount.isZero()) { + throw new InvalidWalletStateException("Invalid reversal reference, time or fee policy"); } - reversalTransactionId = reversal.getId(); - reversedAt = reversal.settledAt; - status = TransactionStatus.REVERSED; + } } + } - private void validateSnapshot(TransactionStatus status, String reason, Instant settlement, - TransactionId reversalId, Instant reversalTime) { - if (settlement != null) { - validateTime(settlement); - } - switch (status) { - case INITIATED, PENDING_SETTLEMENT -> { - if (reason != null || settlement != null || reversalId != null || reversalTime != null) { - throw new InvalidWalletStateException("Open transaction contains terminal metadata"); - } - } - case FAILED -> { - DomainValidation.requireText(reason, "Failure reason"); - if (settlement != null || reversalId != null || reversalTime != null) { - throw new InvalidWalletStateException("Failed transaction contains settlement metadata"); - } - } - case SUCCESSFUL -> { - if (reason != null || settlement == null || reversalId != null || reversalTime != null) { - throw new InvalidWalletStateException("Successful transaction metadata is inconsistent"); - } - } - case REVERSED -> { - if (reason != null || settlement == null || reversalId == null || reversalTime == null) { - throw new InvalidWalletStateException("Reversed transaction metadata is incomplete"); - } - if (getId().equals(reversalId) || reversalTime.isBefore(settlement) || !feeAmount.isZero()) { - throw new InvalidWalletStateException("Invalid reversal reference, time or fee policy"); - } - } - } + private void assertOpen() { + if (status == TransactionStatus.SUCCESSFUL) { + throw new TransactionAlreadySettledException("Transaction is already settled"); } - - private void assertOpen() { - if (status == TransactionStatus.SUCCESSFUL) { - throw new TransactionAlreadySettledException("Transaction is already settled"); - } - if (status != TransactionStatus.INITIATED && status != TransactionStatus.PENDING_SETTLEMENT) { - throw new InvalidWalletStateException("Transaction is terminal: " + status); - } + if (status != TransactionStatus.INITIATED && status != TransactionStatus.PENDING_SETTLEMENT) { + throw new InvalidWalletStateException("Transaction is terminal: " + status); } + } - private void validateTime(Instant now) { - DomainValidation.requireNonNull(now, "Operation time"); - if (now.isBefore(createdAt)) { - throw new InvalidWalletStateException("Operation time precedes transaction creation"); - } + private void validateTime(Instant now) { + DomainValidation.requireNonNull(now, "Operation time"); + if (now.isBefore(createdAt)) { + throw new InvalidWalletStateException("Operation time precedes transaction creation"); } + } - /** - * A local collision key, deliberately not a new public domain abstraction. - */ - private record AccountSequence(AccountId accountId, long sequenceNumber) { - } + /** A local collision key, deliberately not a new public domain abstraction. */ + private record AccountSequence(AccountId accountId, long sequenceNumber) {} - /** - * Returns the exact client-supplied retry key; uniqueness is scoped in persistence. - */ - public IdempotencyKey getIdempotencyKey() { - return idempotencyKey; - } + /** Returns the exact client-supplied retry key; uniqueness is scoped in persistence. */ + public IdempotencyKey getIdempotencyKey() { + return idempotencyKey; + } - /** - * Returns the business transaction classification. - */ - public TransactionType getType() { - return type; - } + /** Returns the business transaction classification. */ + public TransactionType getType() { + return type; + } - /** - * Returns the account paying principal plus fee. - */ - public AccountId getSourceAccountId() { - return sourceAccountId; - } + /** Returns the account paying principal plus fee. */ + public AccountId getSourceAccountId() { + return sourceAccountId; + } - /** - * Returns the account receiving the principal. - */ - public AccountId getDestinationAccountId() { - return destinationAccountId; - } + /** Returns the account receiving the principal. */ + public AccountId getDestinationAccountId() { + return destinationAccountId; + } - /** - * Returns the destination principal, excluding the additional source-paid fee. - */ - public Money getTotalAmount() { - return totalAmount; - } + /** Returns the destination principal, excluding the additional source-paid fee. */ + public Money getTotalAmount() { + return totalAmount; + } - /** - * Returns the additional fee, possibly zero. - */ - public Money getFeeAmount() { - return feeAmount; - } + /** Returns the additional fee, possibly zero. */ + public Money getFeeAmount() { + return feeAmount; + } - /** - * Returns the current lifecycle state. - */ - public TransactionStatus getStatus() { - return status; - } + /** Returns the current lifecycle state. */ + public TransactionStatus getStatus() { + return status; + } - /** - * Returns a reason only when the transaction failed. - */ - public Optional getFailureReason() { - return Optional.ofNullable(failureReason); - } + /** Returns a reason only when the transaction failed. */ + public Optional getFailureReason() { + return Optional.ofNullable(failureReason); + } - /** - * Returns the immutable initiation timestamp. - */ - public Instant getCreatedAt() { - return createdAt; - } + /** Returns the immutable initiation timestamp. */ + public Instant getCreatedAt() { + return createdAt; + } - /** - * Returns the original settlement time, retained after reversal. - */ - public Optional getSettledAt() { - return Optional.ofNullable(settledAt); - } + /** Returns the original settlement time, retained after reversal. */ + public Optional getSettledAt() { + return Optional.ofNullable(settledAt); + } - /** - * Returns the successful compensating transaction identity when reversed. - */ - public Optional getReversalTransactionId() { - return Optional.ofNullable(reversalTransactionId); - } + /** Returns the successful compensating transaction identity when reversed. */ + public Optional getReversalTransactionId() { + return Optional.ofNullable(reversalTransactionId); + } - /** - * Returns the compensating settlement time when reversed. - */ - public Optional getReversedAt() { - return Optional.ofNullable(reversedAt); - } + /** Returns the compensating settlement time when reversed. */ + public Optional getReversedAt() { + return Optional.ofNullable(reversedAt); + } } diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java index a7e7a3e..0d3afac 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/model/Wallet.java @@ -22,13 +22,13 @@ /** * A single-currency, non-overdraft customer wallet backed by an explicit ledger account. * - *

Always maintains {@code 0 <= reservedBalance <= bookBalance}. Credits increase - * this customer-liability balance; debits decrease it. This is not a generic GL account. + *

Always maintains {@code 0 <= reservedBalance <= bookBalance}. Credits increase this + * customer-liability balance; debits decrease it. This is not a generic GL account. * - *

Instances are mutable and MUST be confined to one command/unit of work, never - * shared across threads or cached as live aggregates. Repository locking or versioned - * conditional updates are required for concurrent commands. The loaded version is an - * expected persistence version, not an in-memory mutation counter. + *

Instances are mutable and MUST be confined to one command/unit of work, never shared across + * threads or cached as live aggregates. Repository locking or versioned conditional updates are + * required for concurrent commands. The loaded version is an expected persistence version, not an + * in-memory mutation counter. */ public final class Wallet extends AggregateRoot { private final AccountId accountId; @@ -42,9 +42,18 @@ public final class Wallet extends AggregateRoot { private Instant updatedAt; private long lastAppliedSequenceNumber; - private Wallet(WalletId id, AccountId accountId, UserId userId, Currency currency, - WalletStatus status, Money bookBalance, Money reservedBalance, long version, - long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) { + private Wallet( + WalletId id, + AccountId accountId, + UserId userId, + Currency currency, + WalletStatus status, + Money bookBalance, + Money reservedBalance, + long version, + long lastAppliedSequenceNumber, + Instant createdAt, + Instant updatedAt) { super(id); this.accountId = DomainValidation.requireNonNull(accountId, "Ledger account id"); this.userId = DomainValidation.requireNonNull(userId, "User id"); @@ -75,20 +84,50 @@ private Wallet(WalletId id, AccountId accountId, UserId userId, Currency currenc } /** Creates an empty wallet with an explicitly assigned ledger account; emits one event. */ - public static Wallet create(WalletId id, AccountId accountId, UserId userId, - Currency currency, Instant now) { - Wallet wallet = new Wallet(id, accountId, userId, currency, WalletStatus.ACTIVE, - Money.zero(currency), Money.zero(currency), 0L, -1L, now, now); + public static Wallet create( + WalletId id, AccountId accountId, UserId userId, Currency currency, Instant now) { + Wallet wallet = + new Wallet( + id, + accountId, + userId, + currency, + WalletStatus.ACTIVE, + Money.zero(currency), + Money.zero(currency), + 0L, + -1L, + now, + now); wallet.registerEvent(WalletCreatedEvent.now(id, userId, currency, now)); return wallet; } /** Restores validated persisted state without emitting creation or transition events. */ - public static Wallet restore(WalletId id, AccountId accountId, UserId userId, Currency currency, - WalletStatus status, Money bookBalance, Money reservedBalance, long version, - long lastAppliedSequenceNumber, Instant createdAt, Instant updatedAt) { - return new Wallet(id, accountId, userId, currency, status, bookBalance, reservedBalance, - version, lastAppliedSequenceNumber, createdAt, updatedAt); + public static Wallet restore( + WalletId id, + AccountId accountId, + UserId userId, + Currency currency, + WalletStatus status, + Money bookBalance, + Money reservedBalance, + long version, + long lastAppliedSequenceNumber, + Instant createdAt, + Instant updatedAt) { + return new Wallet( + id, + accountId, + userId, + currency, + status, + bookBalance, + reservedBalance, + version, + lastAppliedSequenceNumber, + createdAt, + updatedAt); } /** Returns spendable funds; holds are already excluded from this amount. */ @@ -117,7 +156,7 @@ public void assertCanCredit(Money amount) { public void reserveFunds(Money amount, Instant now) { validateMutationTime(now); assertCanDebit(amount); - reservedBalance = reservedBalance.add(amount); + reservedBalance = reservedBalance.add(amount); updatedAt = now; } @@ -129,29 +168,30 @@ public void unreserveFunds(Money amount, Instant now) { if (reservedBalance.isLessThan(amount)) { throw new InvalidWalletStateException("Cannot release more than the reserved balance"); } - reservedBalance = reservedBalance.subtract(amount); + reservedBalance = reservedBalance.subtract(amount); updatedAt = now; } /** * Applies an ordinary posting after validating status, ownership, funds, time and order. * - *

Entries must be applied in strictly increasing account sequence order. Gaps are - * permitted; sequence allocation and duplicate prevention across processes require SQL - * constraints and a lock or version check. A batch must first pass Transaction validation. + *

Entries must be applied in strictly increasing account sequence order. Gaps are permitted; + * sequence allocation and duplicate prevention across processes require SQL constraints and a + * lock or version check. A batch must first pass Transaction validation. */ public void applyLedgerEntry(LedgerEntry entry, Instant now) { validateEntry(entry, now); - bookBalance = switch (entry.entryType()) { - case CREDIT -> { - assertCanCredit(entry.amount()); - yield bookBalance.add(entry.amount()); - } - case DEBIT -> { - assertCanDebit(entry.amount()); - yield bookBalance.subtract(entry.amount()); - } - }; + bookBalance = + switch (entry.entryType()) { + case CREDIT -> { + assertCanCredit(entry.amount()); + yield bookBalance.add(entry.amount()); + } + case DEBIT -> { + assertCanDebit(entry.amount()); + yield bookBalance.subtract(entry.amount()); + } + }; lastAppliedSequenceNumber = entry.sequenceNumber(); updatedAt = now; } @@ -159,9 +199,9 @@ public void applyLedgerEntry(LedgerEntry entry, Instant now) { /** * Captures reserved funds with a debit, updating book and reserved balances together. * - *

The command handler must validate the matching pending Hold, its wallet, exact - * amount and expiry, then persist Hold, Wallet, balanced postings and outbox in one - * transaction. This method alone does not authorize or identify a Hold. + *

The command handler must validate the matching pending Hold, its wallet, exact amount and + * expiry, then persist Hold, Wallet, balanced postings and outbox in one transaction. This method + * alone does not authorize or identify a Hold. */ public void captureReservedFunds(LedgerEntry debitEntry, Instant now) { validateEntry(debitEntry, now); @@ -187,15 +227,19 @@ public void suspend(String reason, Instant now) { /** Suspends debits, preserving any existing credit restriction. */ public void suspendDebits(String reason, Instant now) { - WalletStatus target = status == WalletStatus.SUSPENDED_CREDIT || status == WalletStatus.FROZEN - ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_DEBIT; + WalletStatus target = + status == WalletStatus.SUSPENDED_CREDIT || status == WalletStatus.FROZEN + ? WalletStatus.FROZEN + : WalletStatus.SUSPENDED_DEBIT; changeStatus(target, reason, now); } /** Suspends credits, preserving any existing debit restriction. */ public void suspendCredits(String reason, Instant now) { - WalletStatus target = status == WalletStatus.SUSPENDED_DEBIT || status == WalletStatus.FROZEN - ? WalletStatus.FROZEN : WalletStatus.SUSPENDED_CREDIT; + WalletStatus target = + status == WalletStatus.SUSPENDED_DEBIT || status == WalletStatus.FROZEN + ? WalletStatus.FROZEN + : WalletStatus.SUSPENDED_CREDIT; changeStatus(target, reason, now); } @@ -228,7 +272,8 @@ private void validateEntry(LedgerEntry entry, Instant now) { throw new InvalidWalletStateException("Duplicate or out-of-order ledger entry"); } if (entry.timestamp().isBefore(createdAt) || entry.timestamp().isAfter(now)) { - throw new InvalidWalletStateException("Ledger entry timestamp is outside the allowed interval"); + throw new InvalidWalletStateException( + "Ledger entry timestamp is outside the allowed interval"); } } @@ -281,23 +326,52 @@ private void changeStatus(WalletStatus target, String reason, Instant now) { } /** Returns the explicit ledger account identity, not a conversion of the wallet identity. */ - public AccountId getAccountId() { return accountId; } + public AccountId getAccountId() { + return accountId; + } + /** Returns the external owner reference. */ - public UserId getUserId() { return userId; } + public UserId getUserId() { + return userId; + } + /** Returns the wallet's single currency. */ - public Currency getCurrency() { return currency; } + public Currency getCurrency() { + return currency; + } + /** Returns the current operational state. */ - public WalletStatus getStatus() { return status; } + public WalletStatus getStatus() { + return status; + } + /** Returns the book balance, including reserved funds. */ - public Money getBookBalance() { return bookBalance; } + public Money getBookBalance() { + return bookBalance; + } + /** Returns the reserved part of the book balance. */ - public Money getReservedBalance() { return reservedBalance; } + public Money getReservedBalance() { + return reservedBalance; + } + /** Returns the loaded version for a repository compare-and-update; not a mutation count. */ - public long getVersion() { return version; } + public long getVersion() { + return version; + } + /** Returns the last applied sequence, or -1 before any entry has been applied. */ - public long getLastAppliedSequenceNumber() { return lastAppliedSequenceNumber; } + public long getLastAppliedSequenceNumber() { + return lastAppliedSequenceNumber; + } + /** Returns the immutable creation timestamp. */ - public Instant getCreatedAt() { return createdAt; } + public Instant getCreatedAt() { + return createdAt; + } + /** Returns the time of the last effective state change. */ - public Instant getUpdatedAt() { return updatedAt; } + public Instant getUpdatedAt() { + return updatedAt; + } } diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java index e1fe6c0..c8cb97f 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/IdempotencyKey.java @@ -8,12 +8,12 @@ /** * An exact, case-sensitive, client-supplied idempotency key. * - *

The protocol for this Wallet module accepts 1 to 255 ASCII characters from - * A-Z, a-z, 0-9, period, underscore, tilde and hyphen. Keys are never trimmed, - * case-folded or otherwise normalized. + *

The protocol for this Wallet module accepts 1 to 255 ASCII characters from A-Z, a-z, 0-9, + * period, underscore, tilde and hyphen. Keys are never trimmed, case-folded or otherwise + * normalized. * - *

Tenant/operation scope, request fingerprint, status, and timestamps belong - * to the persisted idempotency record, not to this value object. + *

Tenant/operation scope, request fingerprint, status, and timestamps belong to the persisted + * idempotency record, not to this value object. * * @param value the exact idempotency key */ diff --git a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java index 70f0d0c..842cad7 100644 --- a/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java +++ b/payguard-wallet/src/main/java/dev/amg/payguard/domain/value_object/Money.java @@ -3,17 +3,18 @@ import dev.amg.payguard.domain.exception.CurrencyMismatchException; import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException; import domain.DomainValidation; +import domain.ValueObject; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Currency; -import domain.ValueObject; +import org.jspecify.annotations.NonNull; /** * Exact, immutable monetary amount with its currency. * - *

Values are normalized to the currency's default ISO 4217 minor-unit scale; - * no implicit rounding is allowed. Negative values are valid; operation-specific - * sign constraints belong to the Wallet domain rules. + *

Values are normalized to the currency's default ISO 4217 minor-unit scale; no implicit + * rounding is allowed. Negative values are valid; operation-specific sign constraints belong to the + * Wallet domain rules. * *

Comparison is supported only for equal currencies. * @@ -92,8 +93,7 @@ public Money negate() { /** Asserts that the monetary value is zero or positive. */ public void assertNonNegative() { if (isNegative()) { - throw new NegativeAmountNotAllowedException( - "Amount cannot be negative: " + amount); + throw new NegativeAmountNotAllowedException("Amount cannot be negative: " + amount); } } @@ -138,7 +138,9 @@ public boolean isLessThanOrEqual(Money other) { * @throws CurrencyMismatchException for different currencies */ @Override - public int compareTo(Money other) { + // Records generate equals and hashCode for the normalized amount and currency. + @SuppressWarnings("PMD.OverrideBothEqualsAndHashCodeOnComparable") + public int compareTo(@NonNull Money other) { assertSameCurrency(other); return amount.compareTo(other.amount); } diff --git a/payguard-wallet/src/main/resources/application.yaml b/payguard-wallet/src/main/resources/application.yaml index f0db46a..1438f0a 100644 --- a/payguard-wallet/src/main/resources/application.yaml +++ b/payguard-wallet/src/main/resources/application.yaml @@ -4,18 +4,22 @@ spring: datasource: url: ${SPRING_DATASOURCE_URL} driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + connection-timeout: 5000 + validation-timeout: 2000 + max-lifetime: 300000 + keepalive-time: 30000 jooq: sql-dialect: POSTGRES - + flyway: + enabled: false payguard: vault: enabled: true address: ${VAULT_ADDR} ca-file: ${VAULT_CA_FILE} - role-id-file: ${VAULT_ROLE_ID_FILE} - secret-id-file: ${VAULT_SECRET_ID_FILE} - database-role: ${PAYGUARD_ENVIRONMENT:local}-wallet-runtime - + token-file: ${VAULT_TOKEN_FILE} management: endpoint: health: @@ -28,3 +32,6 @@ management: web: exposure: include: health,info +server: + shutdown: graceful +spring.lifecycle.timeout-per-shutdown-phase: 20s diff --git a/payguard-wallet-service/src/main/resources/db/migration/postgresql/V1__wallet_schema_marker.sql b/payguard-wallet/src/main/resources/db/migration/postgresql/V1__wallet_schema_marker.sql similarity index 100% rename from payguard-wallet-service/src/main/resources/db/migration/postgresql/V1__wallet_schema_marker.sql rename to payguard-wallet/src/main/resources/db/migration/postgresql/V1__wallet_schema_marker.sql diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java index 10a4707..a0cf9b9 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/DomainFixtures.java @@ -26,72 +26,155 @@ final class DomainFixtures { private DomainFixtures() {} /** Creates reproducible, distinct identifiers without relying on random collisions. */ - static UUID uuid(long value) { return new UUID(0L, value); } + static UUID uuid(long value) { + return new UUID(0L, value); + } + /** Creates exact CHF money. */ - static Money money(String value) { return Money.of(value, CHF); } + static Money money(String value) { + return Money.of(value, CHF); + } + /** Restores a funded active wallet with a known loaded version and posting high-water mark. */ - static Wallet wallet() { return wallet(WalletStatus.ACTIVE, "100.00", "0.00"); } + static Wallet wallet() { + return wallet(WalletStatus.ACTIVE, "100.00", "0.00"); + } + /** Restores a requested wallet state for focused invariant tests. */ static Wallet wallet(WalletStatus status, String book, String reserved) { - return Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, status, - money(book), money(reserved), 7L, 9L, CREATED, CREATED); + return Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + status, + money(book), + money(reserved), + 7L, + 9L, + CREATED, + CREATED); } + /** Creates a positive full-amount reservation with a deterministic deadline. */ static Hold hold() { - return Hold.createNew(HOLD_ID, WALLET_ID, money("30.00"), "Purchase", "order-1", DEADLINE, CREATED); + return Hold.createNew( + HOLD_ID, WALLET_ID, money("30.00"), "Purchase", "order-1", DEADLINE, CREATED); } + /** Creates an initiated transfer with an additional source-paid fee. */ static Transaction transaction(String principal, String fee) { - return Transaction.initiate(TRANSACTION_ID, IdempotencyKey.of("request-1"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, - money(principal), money(fee), CREATED); + return Transaction.initiate( + TRANSACTION_ID, + IdempotencyKey.of("request-1"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money(principal), + money(fee), + CREATED); } + /** Creates one independently identified posting. */ - static LedgerEntry entry(long entryId, TransactionId transactionId, AccountId accountId, - Money amount, LedgerEntryType type, long sequence, Instant timestamp) { - return new LedgerEntry(new LedgerEntryId(uuid(entryId)), transactionId, accountId, - amount, type, sequence, timestamp); + static LedgerEntry entry( + long entryId, + TransactionId transactionId, + AccountId accountId, + Money amount, + LedgerEntryType type, + long sequence, + Instant timestamp) { + return new LedgerEntry( + new LedgerEntryId(uuid(entryId)), + transactionId, + accountId, + amount, + type, + sequence, + timestamp); } + /** Creates a posting for the standard wallet and transaction. */ static LedgerEntry walletEntry(LedgerEntryType type, String amount, long sequence) { return entry(100 + sequence, TRANSACTION_ID, ACCOUNT_ID, money(amount), type, sequence, NOW); } + /** Creates a balanced batch obeying the principal/fee allocation policy. */ static List postings(Transaction transaction) { List entries = new ArrayList<>(); Instant timestamp = transaction.getCreatedAt().plusSeconds(1); - entries.add(entry(100, transaction.getId(), transaction.getSourceAccountId(), - transaction.getTotalAmount().add(transaction.getFeeAmount()), LedgerEntryType.DEBIT, 10, timestamp)); - entries.add(entry(101, transaction.getId(), transaction.getDestinationAccountId(), - transaction.getTotalAmount(), LedgerEntryType.CREDIT, 10, timestamp)); + entries.add( + entry( + 100, + transaction.getId(), + transaction.getSourceAccountId(), + transaction.getTotalAmount().add(transaction.getFeeAmount()), + LedgerEntryType.DEBIT, + 10, + timestamp)); + entries.add( + entry( + 101, + transaction.getId(), + transaction.getDestinationAccountId(), + transaction.getTotalAmount(), + LedgerEntryType.CREDIT, + 10, + timestamp)); if (!transaction.getFeeAmount().isZero()) { - entries.add(entry(102, transaction.getId(), FEE_ACCOUNT_ID, transaction.getFeeAmount(), - LedgerEntryType.CREDIT, 10, timestamp)); + entries.add( + entry( + 102, + transaction.getId(), + FEE_ACCOUNT_ID, + transaction.getFeeAmount(), + LedgerEntryType.CREDIT, + 10, + timestamp)); } return entries; } + /** Creates and settles a zero-fee original transaction for reversal tests. */ static Transaction settledTransaction() { Transaction transaction = transaction("25.00", "0.00"); transaction.markSettled(postings(transaction), NOW); return transaction; } + /** Creates a distinct, fully settled compensating refund. */ static Transaction refund(Transaction original) { - Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), - IdempotencyKey.of("refund-1"), TransactionType.REFUND, - original.getDestinationAccountId(), original.getSourceAccountId(), - original.getTotalAmount(), money("0.00"), NOW.plusSeconds(1)); + Transaction refund = + Transaction.initiate( + new TransactionId(uuid(20)), + IdempotencyKey.of("refund-1"), + TransactionType.REFUND, + original.getDestinationAccountId(), + original.getSourceAccountId(), + original.getTotalAmount(), + money("0.00"), + NOW.plusSeconds(1)); refund.markSettled(postings(refund), NOW.plusSeconds(3)); return refund; } + /** Captures all mutable wallet state relevant to rejection atomicity. */ static WalletSnapshot snapshot(Wallet wallet) { - return new WalletSnapshot(wallet.getStatus(), wallet.getBookBalance(), - wallet.getReservedBalance(), wallet.getUpdatedAt(), wallet.getVersion(), + return new WalletSnapshot( + wallet.getStatus(), + wallet.getBookBalance(), + wallet.getReservedBalance(), + wallet.getUpdatedAt(), + wallet.getVersion(), wallet.getLastAppliedSequenceNumber()); } + /** Immutable test snapshot for exact before/after comparison. */ - record WalletSnapshot(WalletStatus status, Money book, Money reserved, Instant updated, - long version, long sequence) {} + record WalletSnapshot( + WalletStatus status, + Money book, + Money reserved, + Instant updated, + long version, + long sequence) {} } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java index 4d492a8..2b9eb34 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/HoldTest.java @@ -9,8 +9,6 @@ import dev.amg.payguard.domain.value_object.*; import domain.InvalidDomainArgumentException; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Test; /** Tests expiry boundaries, terminal states, event contents and restore invariants. */ @@ -98,36 +96,63 @@ void shouldReleaseOverduePendingHold() { /** Zero reservations are not valid domain operations. */ @Test void shouldRejectZeroHold() { - assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("0"), "reason", "reference", DEADLINE, CREATED)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.createNew( + HOLD_ID, WALLET_ID, money("0"), "reason", "reference", DEADLINE, CREATED)); } /** Negative reservation amounts are rejected. */ @Test void shouldRejectNegativeHold() { - assertThrows(NegativeAmountNotAllowedException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("-1"), "reason", "reference", DEADLINE, CREATED)); + assertThrows( + NegativeAmountNotAllowedException.class, + () -> + Hold.createNew( + HOLD_ID, WALLET_ID, money("-1"), "reason", "reference", DEADLINE, CREATED)); } /** A newly created hold must have a nonempty validity interval. */ @Test void shouldRejectDeadlineEqualToCreation() { - assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("1"), "reason", "reference", CREATED, CREATED)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.createNew( + HOLD_ID, WALLET_ID, money("1"), "reason", "reference", CREATED, CREATED)); } /** Rejects a reversed validity interval. */ @Test void shouldRejectDeadlineBeforeCreation() { - assertThrows(InvalidHoldStateException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("1"), "reason", "reference", CREATED.minusNanos(1), CREATED)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.createNew( + HOLD_ID, + WALLET_ID, + money("1"), + "reason", + "reference", + CREATED.minusNanos(1), + CREATED)); } /** Restores valid terminal state without replaying a captured event. */ @Test void shouldRestoreCapturedWithoutEvents() { - Hold hold = Hold.restore(HOLD_ID, WALLET_ID, money("30"), "reason", "reference", - DEADLINE, CREATED, HoldStatus.CAPTURED, NOW); + Hold hold = + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.CAPTURED, + NOW); assertEquals(HoldStatus.CAPTURED, hold.getStatus()); assertTrue(hold.pullDomainEvents().isEmpty()); } @@ -228,56 +253,114 @@ void shouldRejectTransitionsAfterExpired() { /** Restoration enforces the same lifecycle invariants as normal commands. */ @Test void shouldRejectPendingWithResolution() { - assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, - money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.PENDING, NOW)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.PENDING, + NOW)); } /** Restoration enforces the same lifecycle invariants as normal commands. */ @Test void shouldRejectTerminalWithoutResolution() { - assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, - money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, null)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.CAPTURED, + null)); } /** Restoration enforces the same lifecycle invariants as normal commands. */ @Test void shouldRejectLateCapturedSnapshot() { - assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, - money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.CAPTURED, DEADLINE)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.CAPTURED, + DEADLINE)); } /** Restoration enforces the same lifecycle invariants as normal commands. */ @Test void shouldRejectPrematureExpiredSnapshot() { - assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, - money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.EXPIRED, NOW)); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.EXPIRED, + NOW)); } /** Restoration enforces the same lifecycle invariants as normal commands. */ @Test void shouldRejectPreCreationResolution() { - assertThrows(InvalidHoldStateException.class, () -> Hold.restore(HOLD_ID, WALLET_ID, - money("30"), "reason", "reference", DEADLINE, CREATED, HoldStatus.RELEASED, CREATED.minusNanos(1))); + assertThrows( + InvalidHoldStateException.class, + () -> + Hold.restore( + HOLD_ID, + WALLET_ID, + money("30"), + "reason", + "reference", + DEADLINE, + CREATED, + HoldStatus.RELEASED, + CREATED.minusNanos(1))); } /** Rejects missing descriptive metadata before construction. */ @Test void shouldRejectBlankReason() { - assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("1"), " ", "ref", DEADLINE, CREATED)); + assertThrows( + InvalidDomainArgumentException.class, + () -> Hold.createNew(HOLD_ID, WALLET_ID, money("1"), " ", "ref", DEADLINE, CREATED)); } /** Rejects missing descriptive metadata before construction. */ @Test void shouldRejectBlankReference() { - assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("1"), "reason", " ", DEADLINE, CREATED)); + assertThrows( + InvalidDomainArgumentException.class, + () -> Hold.createNew(HOLD_ID, WALLET_ID, money("1"), "reason", " ", DEADLINE, CREATED)); } /** Rejects missing descriptive metadata before construction. */ @Test void shouldRejectNullReason() { - assertThrows(InvalidDomainArgumentException.class, () -> Hold.createNew(HOLD_ID, WALLET_ID, - money("1"), null, "ref", DEADLINE, CREATED)); + assertThrows( + InvalidDomainArgumentException.class, + () -> Hold.createNew(HOLD_ID, WALLET_ID, money("1"), null, "ref", DEADLINE, CREATED)); } } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java index c0a5522..7e5e9ad 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/LedgerEntryTest.java @@ -8,9 +8,6 @@ import dev.amg.payguard.domain.exception.*; import dev.amg.payguard.domain.value_object.*; import domain.InvalidDomainArgumentException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Test; /** Tests posting-local invariants and explicit entity equality. */ @@ -20,7 +17,8 @@ class LedgerEntryTest { @Test void shouldPreserveAllPostingComponents() { LedgerEntry entry = walletEntry(LedgerEntryType.DEBIT, "10.00", 10); - assertAll(() -> assertEquals(TRANSACTION_ID, entry.transactionId()), + assertAll( + () -> assertEquals(TRANSACTION_ID, entry.transactionId()), () -> assertEquals(ACCOUNT_ID, entry.accountId()), () -> assertEquals(money("10.00"), entry.amount()), () -> assertEquals(LedgerEntryType.DEBIT, entry.entryType()), @@ -31,70 +29,130 @@ void shouldPreserveAllPostingComponents() { /** Rejects a null Id before a valid entry can exist. */ @Test void shouldRejectNullId() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(null, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + null, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW)); } /** Rejects a null TransactionId before a valid entry can exist. */ @Test void shouldRejectNullTransactionId() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), null, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + new LedgerEntryId(uuid(100)), + null, + ACCOUNT_ID, + money("1"), + LedgerEntryType.DEBIT, + 0, + NOW)); } /** Rejects a null AccountId before a valid entry can exist. */ @Test void shouldRejectNullAccountId() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, null, money("1"), LedgerEntryType.DEBIT, 0, NOW)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + new LedgerEntryId(uuid(100)), + TRANSACTION_ID, + null, + money("1"), + LedgerEntryType.DEBIT, + 0, + NOW)); } /** Rejects a null Amount before a valid entry can exist. */ @Test void shouldRejectNullAmount() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, null, LedgerEntryType.DEBIT, 0, NOW)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + new LedgerEntryId(uuid(100)), + TRANSACTION_ID, + ACCOUNT_ID, + null, + LedgerEntryType.DEBIT, + 0, + NOW)); } /** Rejects a null EntryType before a valid entry can exist. */ @Test void shouldRejectNullEntryType() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), null, 0, NOW)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + new LedgerEntryId(uuid(100)), + TRANSACTION_ID, + ACCOUNT_ID, + money("1"), + null, + 0, + NOW)); } /** Rejects a null Timestamp before a valid entry can exist. */ @Test void shouldRejectNullTimestamp() { - assertThrows(InvalidDomainArgumentException.class, () -> new LedgerEntry(new LedgerEntryId(uuid(100)), TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.DEBIT, 0, null)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + new LedgerEntry( + new LedgerEntryId(uuid(100)), + TRANSACTION_ID, + ACCOUNT_ID, + money("1"), + LedgerEntryType.DEBIT, + 0, + null)); } /** Rejects zero-magnitude entries rather than posting meaningless ledger lines. */ @Test void shouldRejectZeroPosting() { - assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.DEBIT, "0", 10)); + assertThrows( + InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.DEBIT, "0", 10)); } /** Rejects signed amounts because direction belongs to the entry type. */ @Test void shouldRejectNegativePosting() { - assertThrows(NegativeAmountNotAllowedException.class, () -> walletEntry(LedgerEntryType.DEBIT, "-1", 10)); + assertThrows( + NegativeAmountNotAllowedException.class, + () -> walletEntry(LedgerEntryType.DEBIT, "-1", 10)); } /** Rejects invalid account sequence values. */ @Test void shouldRejectNegativeSequence() { - assertThrows(InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.CREDIT, "1", -1)); + assertThrows( + InvalidWalletStateException.class, () -> walletEntry(LedgerEntryType.CREDIT, "1", -1)); } /** Accepts both ends of the non-negative sequence domain. */ @Test void shouldAcceptZeroAndMaximumSequence() { assertEquals(0L, walletEntry(LedgerEntryType.DEBIT, "1", 0).sequenceNumber()); - assertEquals(Long.MAX_VALUE, walletEntry(LedgerEntryType.DEBIT, "1", Long.MAX_VALUE).sequenceNumber()); + assertEquals( + Long.MAX_VALUE, walletEntry(LedgerEntryType.DEBIT, "1", Long.MAX_VALUE).sequenceNumber()); } /** Preserves the existing entity-by-id equality contract, not content equivalence. */ @Test void shouldCompareByIdentity() { LedgerEntry first = walletEntry(LedgerEntryType.DEBIT, "1", 10); - LedgerEntry second = new LedgerEntry(first.id(), TRANSACTION_ID, ACCOUNT_ID, - money("2"), LedgerEntryType.CREDIT, 11, NOW); + LedgerEntry second = + new LedgerEntry( + first.id(), TRANSACTION_ID, ACCOUNT_ID, money("2"), LedgerEntryType.CREDIT, 11, NOW); assertEquals(first, second); assertEquals(first.hashCode(), second.hashCode()); assertNotEquals(first, walletEntry(LedgerEntryType.DEBIT, "1", 11)); diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java index 1e49f15..8f530bd 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/TransactionTest.java @@ -8,7 +8,6 @@ import dev.amg.payguard.domain.exception.*; import dev.amg.payguard.domain.value_object.*; import domain.InvalidDomainArgumentException; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -88,24 +87,44 @@ void shouldRejectNegativeFee() { /** Even a zero fee must carry the principal currency. */ @Test void shouldRejectMixedFeeCurrency() { - assertThrows(CurrencyMismatchException.class, () -> Transaction.initiate(TRANSACTION_ID, - IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, - money("1"), Money.zero(USD), CREATED)); + assertThrows( + CurrencyMismatchException.class, + () -> + Transaction.initiate( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("1"), + Money.zero(USD), + CREATED)); } /** Source and destination are distinct under the selected transfer contract. */ @Test void shouldRejectSelfTransfer() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.initiate(TRANSACTION_ID, - IdempotencyKey.of("key"), TransactionType.P2P_TRANSFER, ACCOUNT_ID, ACCOUNT_ID, - money("1"), money("0"), CREATED)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.initiate( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + ACCOUNT_ID, + money("1"), + money("0"), + CREATED)); } /** Regression: a null time cannot leave status marked successful. */ @Test void shouldRejectNullSettlementTimeWithoutMutation() { Transaction transaction = transaction("100", "0"); - assertThrows(InvalidDomainArgumentException.class, () -> transaction.markSettled(postings(transaction), null)); + assertThrows( + InvalidDomainArgumentException.class, + () -> transaction.markSettled(postings(transaction), null)); assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); assertTrue(transaction.getSettledAt().isEmpty()); } @@ -126,7 +145,9 @@ void shouldMakeFailedStateTerminal() { Transaction transaction = transaction("100", "0"); transaction.markFailed("Rejected by risk policy"); assertEquals("Rejected by risk policy", transaction.getFailureReason().orElseThrow()); - assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(postings(transaction), NOW)); + assertThrows( + InvalidWalletStateException.class, + () -> transaction.markSettled(postings(transaction), NOW)); assertThrows(InvalidWalletStateException.class, () -> transaction.markFailed("replace")); assertThrows(InvalidWalletStateException.class, transaction::markPendingSettlement); } @@ -135,8 +156,11 @@ void shouldMakeFailedStateTerminal() { @Test void shouldPreventSecondSettlement() { Transaction transaction = settledTransaction(); - assertThrows(TransactionAlreadySettledException.class, () -> transaction.markSettled(postings(transaction), NOW)); - assertThrows(TransactionAlreadySettledException.class, () -> transaction.markFailed("late failure")); + assertThrows( + TransactionAlreadySettledException.class, + () -> transaction.markSettled(postings(transaction), NOW)); + assertThrows( + TransactionAlreadySettledException.class, () -> transaction.markFailed("late failure")); } /** Rejects a missing posting batch before settlement. */ @@ -160,16 +184,28 @@ void shouldRejectNullEntry() { @Test void shouldRejectEmptyOrSingleEntryBatch() { Transaction transaction = transaction("100", "0"); - assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(), NOW)); - assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(postings(transaction).getFirst()), NOW)); + assertThrows( + DoubleEntryImbalanceException.class, () -> transaction.markSettled(List.of(), NOW)); + assertThrows( + DoubleEntryImbalanceException.class, + () -> transaction.markSettled(List.of(postings(transaction).getFirst()), NOW)); } /** Unbalanced totals must not mark the transaction successful. */ @Test void shouldRejectUnbalancedBatchWithoutMutation() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, DESTINATION_ID, money("99.99"), LedgerEntryType.CREDIT, 10, NOW)); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + TRANSACTION_ID, + DESTINATION_ID, + money("99.99"), + LedgerEntryType.CREDIT, + 10, + NOW)); assertThrows(DoubleEntryImbalanceException.class, () -> transaction.markSettled(entries, NOW)); assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); assertTrue(transaction.getSettledAt().isEmpty()); @@ -179,9 +215,11 @@ void shouldRejectUnbalancedBatchWithoutMutation() { @Test void shouldRejectBalancedButIncorrectPrincipal() { Transaction transaction = transaction("100", "0"); - List entries = List.of( - entry(100, TRANSACTION_ID, ACCOUNT_ID, money("99"), LedgerEntryType.DEBIT, 10, NOW), - entry(101, TRANSACTION_ID, DESTINATION_ID, money("99"), LedgerEntryType.CREDIT, 10, NOW)); + List entries = + List.of( + entry(100, TRANSACTION_ID, ACCOUNT_ID, money("99"), LedgerEntryType.DEBIT, 10, NOW), + entry( + 101, TRANSACTION_ID, DESTINATION_ID, money("99"), LedgerEntryType.CREDIT, 10, NOW)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -189,8 +227,17 @@ void shouldRejectBalancedButIncorrectPrincipal() { @Test void shouldRejectWrongFeeAllocation() { Transaction transaction = transaction("100", "2"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, DESTINATION_ID, money("102"), LedgerEntryType.CREDIT, 10, NOW)); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + TRANSACTION_ID, + DESTINATION_ID, + money("102"), + LedgerEntryType.CREDIT, + 10, + NOW)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -199,8 +246,17 @@ void shouldRejectWrongFeeAllocation() { void shouldAcceptSplitFeeCredits() { Transaction transaction = transaction("100", "2"); List entries = new ArrayList<>(postings(transaction).subList(0, 2)); - entries.add(entry(102, TRANSACTION_ID, FEE_ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW)); - entries.add(entry(103, TRANSACTION_ID, new AccountId(uuid(40)), money("1"), LedgerEntryType.CREDIT, 10, NOW)); + entries.add( + entry(102, TRANSACTION_ID, FEE_ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW)); + entries.add( + entry( + 103, + TRANSACTION_ID, + new AccountId(uuid(40)), + money("1"), + LedgerEntryType.CREDIT, + 10, + NOW)); transaction.markSettled(entries, NOW); assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); } @@ -210,19 +266,29 @@ void shouldAcceptSplitFeeCredits() { void shouldRejectDuplicateEntryIdentity() { Transaction transaction = transaction("100", "0"); LedgerEntry debit = postings(transaction).getFirst(); - LedgerEntry credit = new LedgerEntry(debit.id(), TRANSACTION_ID, DESTINATION_ID, - money("100"), LedgerEntryType.CREDIT, 10, NOW); - assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(List.of(debit, credit), NOW)); + LedgerEntry credit = + new LedgerEntry( + debit.id(), + TRANSACTION_ID, + DESTINATION_ID, + money("100"), + LedgerEntryType.CREDIT, + 10, + NOW); + assertThrows( + InvalidWalletStateException.class, + () -> transaction.markSettled(List.of(debit, credit), NOW)); } /** Two distinct entries cannot claim the same sequence in one account. */ @Test void shouldRejectDuplicateAccountSequence() { Transaction transaction = transaction("100", "0"); - List entries = List.of( - entry(100, TRANSACTION_ID, ACCOUNT_ID, money("40"), LedgerEntryType.DEBIT, 10, NOW), - entry(102, TRANSACTION_ID, ACCOUNT_ID, money("60"), LedgerEntryType.DEBIT, 10, NOW), - postings(transaction).get(1)); + List entries = + List.of( + entry(100, TRANSACTION_ID, ACCOUNT_ID, money("40"), LedgerEntryType.DEBIT, 10, NOW), + entry(102, TRANSACTION_ID, ACCOUNT_ID, money("60"), LedgerEntryType.DEBIT, 10, NOW), + postings(transaction).get(1)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -230,8 +296,17 @@ void shouldRejectDuplicateAccountSequence() { @Test void shouldRejectForeignTransactionEntry() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, new TransactionId(uuid(99)), DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW)); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + new TransactionId(uuid(99)), + DESTINATION_ID, + money("100"), + LedgerEntryType.CREDIT, + 10, + NOW)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -239,8 +314,17 @@ void shouldRejectForeignTransactionEntry() { @Test void shouldRejectPostingCurrencyMismatch() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, DESTINATION_ID, Money.of("100", USD), LedgerEntryType.CREDIT, 10, NOW)); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + TRANSACTION_ID, + DESTINATION_ID, + Money.of("100", USD), + LedgerEntryType.CREDIT, + 10, + NOW)); assertThrows(CurrencyMismatchException.class, () -> transaction.markSettled(entries, NOW)); } @@ -248,9 +332,11 @@ void shouldRejectPostingCurrencyMismatch() { @Test void shouldRejectDebitFromDifferentAccount() { Transaction transaction = transaction("100", "0"); - List entries = List.of( - entry(100, TRANSACTION_ID, FEE_ACCOUNT_ID, money("100"), LedgerEntryType.DEBIT, 10, NOW), - postings(transaction).get(1)); + List entries = + List.of( + entry( + 100, TRANSACTION_ID, FEE_ACCOUNT_ID, money("100"), LedgerEntryType.DEBIT, 10, NOW), + postings(transaction).get(1)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -258,8 +344,10 @@ void shouldRejectDebitFromDifferentAccount() { @Test void shouldRejectCreditBackToSource() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, ACCOUNT_ID, money("100"), LedgerEntryType.CREDIT, 11, NOW)); + List entries = + List.of( + postings(transaction).getFirst(), + entry(101, TRANSACTION_ID, ACCOUNT_ID, money("100"), LedgerEntryType.CREDIT, 11, NOW)); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -290,7 +378,8 @@ void shouldKeepReversedStateTerminal() { Transaction original = settledTransaction(); Transaction refund = refund(original); original.markReversed(refund); - assertThrows(InvalidWalletStateException.class, () -> original.markSettled(postings(original), NOW)); + assertThrows( + InvalidWalletStateException.class, () -> original.markSettled(postings(original), NOW)); assertThrows(InvalidWalletStateException.class, () -> original.markFailed("late")); assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); assertThrows(InvalidWalletStateException.class, original::markPendingSettlement); @@ -319,8 +408,16 @@ void shouldRejectFeeBearingReversalUntilPolicyExists() { @Test void shouldRejectUnsettledRefund() { Transaction original = settledTransaction(); - Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), - TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), NOW); + Transaction refund = + Transaction.initiate( + new TransactionId(uuid(20)), + IdempotencyKey.of("refund"), + TransactionType.REFUND, + DESTINATION_ID, + ACCOUNT_ID, + money("25"), + money("0"), + NOW); assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); } @@ -328,8 +425,16 @@ void shouldRejectUnsettledRefund() { @Test void shouldRejectWrongRefundAmount() { Transaction original = settledTransaction(); - Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), - TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("24"), money("0"), NOW); + Transaction refund = + Transaction.initiate( + new TransactionId(uuid(20)), + IdempotencyKey.of("refund"), + TransactionType.REFUND, + DESTINATION_ID, + ACCOUNT_ID, + money("24"), + money("0"), + NOW); refund.markSettled(postings(refund), NOW.plusSeconds(2)); assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); } @@ -338,8 +443,16 @@ void shouldRejectWrongRefundAmount() { @Test void shouldRejectRefundCreatedBeforeOriginalSettlement() { Transaction original = settledTransaction(); - Transaction refund = Transaction.initiate(new TransactionId(uuid(20)), IdempotencyKey.of("refund"), - TransactionType.REFUND, DESTINATION_ID, ACCOUNT_ID, money("25"), money("0"), CREATED); + Transaction refund = + Transaction.initiate( + new TransactionId(uuid(20)), + IdempotencyKey.of("refund"), + TransactionType.REFUND, + DESTINATION_ID, + ACCOUNT_ID, + money("25"), + money("0"), + CREATED); refund.markSettled(postings(refund), NOW.plusSeconds(2)); assertThrows(InvalidWalletStateException.class, () -> original.markReversed(refund)); } @@ -348,8 +461,17 @@ void shouldRejectRefundCreatedBeforeOriginalSettlement() { @Test void shouldRejectFutureEntryTime() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1))); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + TRANSACTION_ID, + DESTINATION_ID, + money("100"), + LedgerEntryType.CREDIT, + 10, + NOW.plusNanos(1))); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } @@ -357,89 +479,236 @@ void shouldRejectFutureEntryTime() { @Test void shouldRejectPreCreationEntryTime() { Transaction transaction = transaction("100", "0"); - List entries = List.of(postings(transaction).getFirst(), - entry(101, TRANSACTION_ID, DESTINATION_ID, money("100"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1))); + List entries = + List.of( + postings(transaction).getFirst(), + entry( + 101, + TRANSACTION_ID, + DESTINATION_ID, + money("100"), + LedgerEntryType.CREDIT, + 10, + CREATED.minusNanos(1))); assertThrows(InvalidWalletStateException.class, () -> transaction.markSettled(entries, NOW)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectOpenWithSettlementSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.INITIATED, null, CREATED, NOW, null, null)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.INITIATED, + null, + CREATED, + NOW, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectPendingWithFailureSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.PENDING_SETTLEMENT, "failure", CREATED, null, null, null)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.PENDING_SETTLEMENT, + "failure", + CREATED, + null, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectFailedWithoutReasonSnapshot() { - assertThrows(InvalidDomainArgumentException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.FAILED, null, CREATED, null, null, null)); + assertThrows( + InvalidDomainArgumentException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.FAILED, + null, + CREATED, + null, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectFailedWithSettlementSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.FAILED, "failure", CREATED, NOW, null, null)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.FAILED, + "failure", + CREATED, + NOW, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectSuccessfulWithoutSettlementSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.SUCCESSFUL, null, CREATED, null, null, null)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.SUCCESSFUL, + null, + CREATED, + null, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectSuccessfulWithFailureSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.SUCCESSFUL, "failure", CREATED, NOW, null, null)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.SUCCESSFUL, + "failure", + CREATED, + NOW, + null, + null)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectReversedWithoutReferenceSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.REVERSED, null, CREATED, NOW, null, NOW)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.REVERSED, + null, + CREATED, + NOW, + null, + NOW)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectReversedWithSelfReferenceSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.REVERSED, null, CREATED, NOW, TRANSACTION_ID, NOW)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.REVERSED, + null, + CREATED, + NOW, + TRANSACTION_ID, + NOW)); } /** Rejects inconsistent status metadata during restoration. */ @Test void shouldRejectReversedWithEarlierTimeSnapshot() { - assertThrows(InvalidWalletStateException.class, () -> Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), CREATED)); + assertThrows( + InvalidWalletStateException.class, + () -> + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.REVERSED, + null, + CREATED, + NOW, + new TransactionId(uuid(20)), + CREATED)); } /** Accepts a consistent stored state without transition events. */ @Test void shouldRestoreInitiatedSnapshot() { - Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.INITIATED, null, CREATED, null, null, null); + Transaction transaction = + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.INITIATED, + null, + CREATED, + null, + null, + null); assertEquals(TransactionStatus.INITIATED, transaction.getStatus()); assertTrue(transaction.pullDomainEvents().isEmpty()); } @@ -447,9 +716,21 @@ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0 /** Accepts a consistent stored state without transition events. */ @Test void shouldRestorePendingSettlementSnapshot() { - Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.PENDING_SETTLEMENT, null, CREATED, null, null, null); + Transaction transaction = + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.PENDING_SETTLEMENT, + null, + CREATED, + null, + null, + null); assertEquals(TransactionStatus.PENDING_SETTLEMENT, transaction.getStatus()); assertTrue(transaction.pullDomainEvents().isEmpty()); } @@ -457,9 +738,21 @@ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0 /** Accepts a consistent stored state without transition events. */ @Test void shouldRestoreFailedSnapshot() { - Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.FAILED, "failure", CREATED, null, null, null); + Transaction transaction = + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.FAILED, + "failure", + CREATED, + null, + null, + null); assertEquals(TransactionStatus.FAILED, transaction.getStatus()); assertTrue(transaction.pullDomainEvents().isEmpty()); } @@ -467,9 +760,21 @@ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0 /** Accepts a consistent stored state without transition events. */ @Test void shouldRestoreSuccessfulSnapshot() { - Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.SUCCESSFUL, null, CREATED, NOW, null, null); + Transaction transaction = + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.SUCCESSFUL, + null, + CREATED, + NOW, + null, + null); assertEquals(TransactionStatus.SUCCESSFUL, transaction.getStatus()); assertTrue(transaction.pullDomainEvents().isEmpty()); } @@ -477,9 +782,21 @@ TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0 /** Accepts a consistent stored state without transition events. */ @Test void shouldRestoreReversedSnapshot() { - Transaction transaction = Transaction.restore(TRANSACTION_ID, IdempotencyKey.of("key"), - TransactionType.P2P_TRANSFER, ACCOUNT_ID, DESTINATION_ID, money("100"), money("0"), - TransactionStatus.REVERSED, null, CREATED, NOW, new TransactionId(uuid(20)), NOW); + Transaction transaction = + Transaction.restore( + TRANSACTION_ID, + IdempotencyKey.of("key"), + TransactionType.P2P_TRANSFER, + ACCOUNT_ID, + DESTINATION_ID, + money("100"), + money("0"), + TransactionStatus.REVERSED, + null, + CREATED, + NOW, + new TransactionId(uuid(20)), + NOW); assertEquals(TransactionStatus.REVERSED, transaction.getStatus()); assertTrue(transaction.pullDomainEvents().isEmpty()); } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java index fb7fe65..b911f2a 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/model/WalletTest.java @@ -8,9 +8,6 @@ import dev.amg.payguard.domain.exception.*; import dev.amg.payguard.domain.value_object.*; import domain.InvalidDomainArgumentException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; import org.junit.jupiter.api.Test; /** Tests wallet invariants, status permissions, posting order and rejection atomicity. */ @@ -86,13 +83,15 @@ void shouldNotReactivateClosedWallet() { /** Prevents restoring an overdraft under the explicit non-overdraft policy. */ @Test void shouldRejectNegativeBookBalance() { - assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "-1", "0")); + assertThrows( + NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "-1", "0")); } /** Rejects corrupt negative reservation snapshots. */ @Test void shouldRejectNegativeReservedBalance() { - assertThrows(NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "100", "-1")); + assertThrows( + NegativeAmountNotAllowedException.class, () -> wallet(WalletStatus.ACTIVE, "100", "-1")); } /** Rejects a restored snapshot with negative availability. */ @@ -152,7 +151,8 @@ void shouldReleaseReservationWhileFrozen() { void shouldRejectExcessReleaseWithoutMutation() { Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); var before = snapshot(wallet); - assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("30.01"), NOW)); + assertThrows( + InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("30.01"), NOW)); assertEquals(before, snapshot(wallet)); } @@ -169,8 +169,15 @@ void shouldCreditExplicitAccount() { @Test void shouldRejectWalletUuidAsAccountShortcut() { Wallet wallet = wallet(); - LedgerEntry entry = entry(100, TRANSACTION_ID, new AccountId(WALLET_ID.value()), - money("10"), LedgerEntryType.CREDIT, 10, NOW); + LedgerEntry entry = + entry( + 100, + TRANSACTION_ID, + new AccountId(WALLET_ID.value()), + money("10"), + LedgerEntryType.CREDIT, + 10, + NOW); var before = snapshot(wallet); assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); assertEquals(before, snapshot(wallet)); @@ -181,7 +188,8 @@ void shouldRejectWalletUuidAsAccountShortcut() { void shouldDebitOnlyAvailableFunds() { Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); var before = snapshot(wallet); - assertThrows(InsufficientFundException.class, + assertThrows( + InsufficientFundException.class, () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70.01", 10), NOW)); assertEquals(before, snapshot(wallet)); wallet.applyLedgerEntry(walletEntry(LedgerEntryType.DEBIT, "70", 10), NOW); @@ -204,7 +212,8 @@ void shouldRejectDuplicateSequenceWithoutMutation() { @Test void shouldRejectOutOfOrderSequence() { Wallet wallet = wallet(); - assertThrows(InvalidWalletStateException.class, + assertThrows( + InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "10", 8), NOW)); } @@ -233,7 +242,8 @@ void shouldCaptureReservedFundsAtomically() { void shouldRejectCaptureUsingCredit() { Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); var before = snapshot(wallet); - assertThrows(InvalidWalletStateException.class, + assertThrows( + InvalidWalletStateException.class, () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.CREDIT, "30", 10), NOW)); assertEquals(before, snapshot(wallet)); } @@ -243,7 +253,8 @@ void shouldRejectCaptureUsingCredit() { void shouldRejectCaptureAboveReservation() { Wallet wallet = wallet(WalletStatus.ACTIVE, "100", "30"); var before = snapshot(wallet); - assertThrows(InvalidWalletStateException.class, + assertThrows( + InvalidWalletStateException.class, () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30.01", 10), NOW)); assertEquals(before, snapshot(wallet)); } @@ -252,7 +263,8 @@ void shouldRejectCaptureAboveReservation() { @Test void shouldRejectFrozenCapture() { Wallet wallet = wallet(WalletStatus.FROZEN, "100", "30"); - assertThrows(WalletFrozenException.class, + assertThrows( + WalletFrozenException.class, () -> wallet.captureReservedFunds(walletEntry(LedgerEntryType.DEBIT, "30", 10), NOW)); } @@ -262,7 +274,9 @@ void shouldRejectNullTimeBeforeChangingBalance() { Wallet wallet = wallet(); var before = snapshot(wallet); assertThrows(InvalidDomainArgumentException.class, () -> wallet.reserveFunds(money("1"), null)); - assertThrows(InvalidDomainArgumentException.class, () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 10), null)); + assertThrows( + InvalidDomainArgumentException.class, + () -> wallet.applyLedgerEntry(walletEntry(LedgerEntryType.CREDIT, "1", 10), null)); assertEquals(before, snapshot(wallet)); } @@ -272,7 +286,8 @@ void shouldRejectBackdatedMutation() { Wallet wallet = wallet(); wallet.reserveFunds(money("1"), NOW); var before = snapshot(wallet); - assertThrows(InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("1"), CREATED)); + assertThrows( + InvalidWalletStateException.class, () -> wallet.unreserveFunds(money("1"), CREATED)); assertEquals(before, snapshot(wallet)); } @@ -396,43 +411,116 @@ void shouldRejectCurrencyMismatchOnDebit() { /** Rejects invalid restored metadata before exposing an aggregate. */ @Test void shouldRejectBookCurrencyOnRestore() { - assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, - WalletStatus.ACTIVE, Money.of("100", USD), money("0"), 0, -1, CREATED, CREATED)); + assertThrows( + CurrencyMismatchException.class, + () -> + Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + WalletStatus.ACTIVE, + Money.of("100", USD), + money("0"), + 0, + -1, + CREATED, + CREATED)); } /** Rejects invalid restored metadata before exposing an aggregate. */ @Test void shouldRejectReservedCurrencyOnRestore() { - assertThrows(CurrencyMismatchException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, - WalletStatus.ACTIVE, money("100"), Money.of("0", USD), 0, -1, CREATED, CREATED)); + assertThrows( + CurrencyMismatchException.class, + () -> + Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + WalletStatus.ACTIVE, + money("100"), + Money.of("0", USD), + 0, + -1, + CREATED, + CREATED)); } /** Rejects invalid restored metadata before exposing an aggregate. */ @Test void shouldRejectNegativeVersionOnRestore() { - assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, - WalletStatus.ACTIVE, money("100"), money("0"), -1, -1, CREATED, CREATED)); + assertThrows( + InvalidWalletStateException.class, + () -> + Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + WalletStatus.ACTIVE, + money("100"), + money("0"), + -1, + -1, + CREATED, + CREATED)); } /** Rejects invalid restored metadata before exposing an aggregate. */ @Test void shouldRejectInvalidSequenceOnRestore() { - assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, - WalletStatus.ACTIVE, money("100"), money("0"), 0, -2, CREATED, CREATED)); + assertThrows( + InvalidWalletStateException.class, + () -> + Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + WalletStatus.ACTIVE, + money("100"), + money("0"), + 0, + -2, + CREATED, + CREATED)); } /** Rejects invalid restored metadata before exposing an aggregate. */ @Test void shouldRejectBackdatedSnapshotOnRestore() { - assertThrows(InvalidWalletStateException.class, () -> Wallet.restore(WALLET_ID, ACCOUNT_ID, USER_ID, CHF, - WalletStatus.ACTIVE, money("100"), money("0"), 0, -1, NOW, CREATED)); + assertThrows( + InvalidWalletStateException.class, + () -> + Wallet.restore( + WALLET_ID, + ACCOUNT_ID, + USER_ID, + CHF, + WalletStatus.ACTIVE, + money("100"), + money("0"), + 0, + -1, + NOW, + CREATED)); } /** Rejects posting timestamps outside the wallet/application interval. */ @Test void shouldRejectFuturePostingTime() { Wallet wallet = wallet(); - LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, NOW.plusNanos(1)); + LedgerEntry entry = + entry( + 100, + TRANSACTION_ID, + ACCOUNT_ID, + money("1"), + LedgerEntryType.CREDIT, + 10, + NOW.plusNanos(1)); assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); } @@ -440,7 +528,15 @@ void shouldRejectFuturePostingTime() { @Test void shouldRejectBeforeCreationPostingTime() { Wallet wallet = wallet(); - LedgerEntry entry = entry(100, TRANSACTION_ID, ACCOUNT_ID, money("1"), LedgerEntryType.CREDIT, 10, CREATED.minusNanos(1)); + LedgerEntry entry = + entry( + 100, + TRANSACTION_ID, + ACCOUNT_ID, + money("1"), + LedgerEntryType.CREDIT, + 10, + CREATED.minusNanos(1)); assertThrows(InvalidWalletStateException.class, () -> wallet.applyLedgerEntry(entry, NOW)); } } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java index 53f2353..8549a8f 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/AccountIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link AccountId}. */ class AccountIdTest { @@ -37,7 +37,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new AccountId(FIXED_ID), + assertEquals( + new AccountId(FIXED_ID), AccountId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -79,7 +80,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new AccountId(FIXED_ID), + assertNotEquals( + new AccountId(FIXED_ID), new AccountId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java index ebefa6b..3062744 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/HoldIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link HoldId}. */ class HoldIdTest { @@ -37,7 +37,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new HoldId(FIXED_ID), + assertEquals( + new HoldId(FIXED_ID), HoldId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -79,8 +80,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new HoldId(FIXED_ID), - new HoldId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + assertNotEquals( + new HoldId(FIXED_ID), new HoldId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } /** A different identifier type must never compare equal even with the same UUID. */ diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java index c0c8520..35ebe11 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/IdempotencyKeyTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import dev.amg.payguard.domain.exception.InvalidIdempotencyKeyException; import domain.InvalidDomainArgumentException; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests exact protocol validation and equality of idempotency keys. */ class IdempotencyKeyTest { @@ -30,8 +30,7 @@ void shouldAcceptMaximumLength() { /** A key longer than the protocol limit must be rejected. */ @Test void shouldRejectExcessiveLength() { - assertThrows(InvalidIdempotencyKeyException.class, - () -> IdempotencyKey.of("a".repeat(256))); + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of("a".repeat(256))); } /** Empty strings are not usable idempotency keys. */ @@ -49,29 +48,25 @@ void shouldRejectBlankString() { /** Leading and trailing whitespace is rejected rather than trimmed. */ @Test void shouldRejectWhitespaceWithoutNormalization() { - assertThrows(InvalidIdempotencyKeyException.class, - () -> IdempotencyKey.of(" abc ")); + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of(" abc ")); } /** Control characters are not valid in a key used in an HTTP header. */ @Test void shouldRejectControlCharacters() { - assertThrows(InvalidIdempotencyKeyException.class, - () -> IdempotencyKey.of("a\nb")); + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of("a\nb")); } /** Non-ASCII characters are rejected by the explicit Wallet API policy. */ @Test void shouldRejectNonAscii() { - assertThrows(InvalidIdempotencyKeyException.class, - () -> IdempotencyKey.of("caf\u00e9")); + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of("caf\u00e9")); } /** Printable separators outside the allowed alphabet are rejected. */ @Test void shouldRejectDisallowedPunctuation() { - assertThrows(InvalidIdempotencyKeyException.class, - () -> IdempotencyKey.of("order/42")); + assertThrows(InvalidIdempotencyKeyException.class, () -> IdempotencyKey.of("order/42")); } /** Case is significant for idempotency semantics. */ diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java index 1ba2b0d..54d8aa8 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/LedgerEntryIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link LedgerEntryId}. */ class LedgerEntryIdTest { @@ -37,7 +37,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new LedgerEntryId(FIXED_ID), + assertEquals( + new LedgerEntryId(FIXED_ID), LedgerEntryId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -79,7 +80,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new LedgerEntryId(FIXED_ID), + assertNotEquals( + new LedgerEntryId(FIXED_ID), new LedgerEntryId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java index 55fa7fa..b494ae7 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/MoneyTest.java @@ -1,5 +1,7 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import dev.amg.payguard.domain.exception.CurrencyMismatchException; import dev.amg.payguard.domain.exception.NegativeAmountNotAllowedException; import domain.InvalidDomainArgumentException; @@ -7,8 +9,6 @@ import java.util.Currency; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests exact financial arithmetic, normalization and currency isolation. */ class MoneyTest { @@ -50,8 +50,8 @@ void shouldRejectSubCentChfAmount() { /** Zero-currency placeholder XXX has no defined minor-unit precision. */ @Test void shouldRejectUndefinedCurrencyPrecision() { - assertThrows(IllegalArgumentException.class, - () -> Money.of("100", Currency.getInstance("XXX"))); + assertThrows( + IllegalArgumentException.class, () -> Money.of("100", Currency.getInstance("XXX"))); } /** Zero is normalized to the chosen currency scale. */ @@ -95,21 +95,22 @@ void shouldAddExactAmountsWithoutMutation() { /** Subtraction may produce a negative Money value. */ @Test void shouldSubtractExactlyIntoNegativeBalance() { - assertEquals(Money.of("-50.00", CHF), - Money.of("100.00", CHF).subtract(Money.of("150.00", CHF))); + assertEquals( + Money.of("-50.00", CHF), Money.of("100.00", CHF).subtract(Money.of("150.00", CHF))); } /** Mixed-currency addition is always rejected. */ @Test void shouldRejectCrossCurrencyAddition() { - assertThrows(CurrencyMismatchException.class, - () -> Money.of("1.00", CHF).add(Money.of("1.00", USD))); + assertThrows( + CurrencyMismatchException.class, () -> Money.of("1.00", CHF).add(Money.of("1.00", USD))); } /** Mixed-currency subtraction is always rejected. */ @Test void shouldRejectCrossCurrencySubtraction() { - assertThrows(CurrencyMismatchException.class, + assertThrows( + CurrencyMismatchException.class, () -> Money.of("1.00", CHF).subtract(Money.of("1.00", USD))); } @@ -122,15 +123,14 @@ void shouldMultiplyByWholeNumber() { /** Decimal multiplication succeeds when the result has valid precision. */ @Test void shouldMultiplyByExactDecimalFactor() { - assertEquals(Money.of("5.00", CHF), - Money.of("10.00", CHF).multiply(new BigDecimal("0.5"))); + assertEquals(Money.of("5.00", CHF), Money.of("10.00", CHF).multiply(new BigDecimal("0.5"))); } /** Decimal multiplication fails rather than silently rounding half cents. */ @Test void shouldRejectInexactDecimalMultiplication() { - assertThrows(ArithmeticException.class, - () -> Money.of("10.01", CHF).multiply(new BigDecimal("0.5"))); + assertThrows( + ArithmeticException.class, () -> Money.of("10.01", CHF).multiply(new BigDecimal("0.5"))); } /** Zero multiplication preserves currency and normalized scale. */ @@ -156,8 +156,8 @@ void shouldReturnAbsoluteValue() { /** A negative amount is rejected where nonnegative is explicitly required. */ @Test void shouldRejectNegativeAmountForNonNegativeInvariant() { - assertThrows(NegativeAmountNotAllowedException.class, - () -> Money.of("-0.01", CHF).assertNonNegative()); + assertThrows( + NegativeAmountNotAllowedException.class, () -> Money.of("-0.01", CHF).assertNonNegative()); } /** Zero and positive amounts satisfy the nonnegative invariant. */ @@ -208,7 +208,8 @@ void shouldEvaluateConvenienceComparisons() { /** Ordering across currencies is not meaningful. */ @Test void shouldRejectCrossCurrencyComparison() { - assertThrows(CurrencyMismatchException.class, + assertThrows( + CurrencyMismatchException.class, () -> Money.of("1.00", CHF).compareTo(Money.of("1.00", USD))); } @@ -224,8 +225,7 @@ void shouldRejectNullOperands() { /** Null amounts are rejected by the shared-domain validator. */ @Test void shouldRejectNullAmount() { - assertThrows(InvalidDomainArgumentException.class, - () -> Money.of((BigDecimal) null, CHF)); + assertThrows(InvalidDomainArgumentException.class, () -> Money.of((BigDecimal) null, CHF)); } /** Null currencies are rejected by the shared-domain validator. */ @@ -237,8 +237,7 @@ void shouldRejectNullCurrency() { /** Null text amounts are rejected before parsing. */ @Test void shouldRejectNullTextAmount() { - assertThrows(InvalidDomainArgumentException.class, - () -> Money.of((String) null, CHF)); + assertThrows(InvalidDomainArgumentException.class, () -> Money.of((String) null, CHF)); } /** Invalid decimal text is rejected rather than coerced. */ @@ -250,22 +249,21 @@ void shouldRejectInvalidDecimalText() { /** Null multiplication factors are rejected by the domain validator. */ @Test void shouldRejectNullDecimalFactor() { - assertThrows(InvalidDomainArgumentException.class, + assertThrows( + InvalidDomainArgumentException.class, () -> Money.of("1.00", CHF).multiply((BigDecimal) null)); } /** Large positive long amounts are represented without double precision loss. */ @Test void shouldAcceptMaximumLongAmount() { - assertEquals(new BigDecimal("9223372036854775807.00"), - Money.of(Long.MAX_VALUE, CHF).amount()); + assertEquals(new BigDecimal("9223372036854775807.00"), Money.of(Long.MAX_VALUE, CHF).amount()); } /** Large negative long amounts are represented without double precision loss. */ @Test void shouldAcceptMinimumLongAmount() { - assertEquals(new BigDecimal("-9223372036854775808.00"), - Money.of(Long.MIN_VALUE, CHF).amount()); + assertEquals(new BigDecimal("-9223372036854775808.00"), Money.of(Long.MIN_VALUE, CHF).amount()); } /** The long factory interprets whole major units, not cents. */ diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java index edf9b78..eb22f31 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/TransactionIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link TransactionId}. */ class TransactionIdTest { @@ -37,7 +37,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new TransactionId(FIXED_ID), + assertEquals( + new TransactionId(FIXED_ID), TransactionId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -79,7 +80,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new TransactionId(FIXED_ID), + assertNotEquals( + new TransactionId(FIXED_ID), new TransactionId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java index b836e05..0fa098d 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/UserIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link UserId}. */ class UserIdTest { @@ -27,7 +27,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new UserId(FIXED_ID), + assertEquals( + new UserId(FIXED_ID), UserId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -69,8 +70,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new UserId(FIXED_ID), - new UserId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); + assertNotEquals( + new UserId(FIXED_ID), new UserId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } /** A different identifier type must never compare equal even with the same UUID. */ diff --git a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java index 05c08ac..797f574 100644 --- a/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java +++ b/payguard-wallet/src/test/java/dev/amg/payguard/domain/value_object/WalletIdTest.java @@ -1,11 +1,11 @@ package dev.amg.payguard.domain.value_object; +import static org.junit.jupiter.api.Assertions.*; + import domain.InvalidDomainArgumentException; import java.util.UUID; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - /** Tests the immutable identifier contract of {@link WalletId}. */ class WalletIdTest { @@ -37,7 +37,8 @@ void shouldParseCanonicalUuid() { /** Uppercase hexadecimal UUID input is allowed. */ @Test void shouldAcceptUppercaseUuid() { - assertEquals(new WalletId(FIXED_ID), + assertEquals( + new WalletId(FIXED_ID), WalletId.fromString(FIXED_ID.toString().toUpperCase(java.util.Locale.ROOT))); } @@ -79,7 +80,8 @@ void shouldPreserveValueEquality() { /** A different UUID must represent a different identifier. */ @Test void shouldRejectDifferentUuidEquality() { - assertNotEquals(new WalletId(FIXED_ID), + assertNotEquals( + new WalletId(FIXED_ID), new WalletId(UUID.fromString("123e4567-e89b-42d3-a456-426614174001"))); } diff --git a/pom.xml b/pom.xml index e5ee5f6..2462671 100644 --- a/pom.xml +++ b/pom.xml @@ -14,11 +14,11 @@ payguard-shared payguard-proto - payguard-api-gateway - payguard-wallet-service - payguard-loan-service - payguard-collateral-service - payguard-identity-service + payguard-gateway + payguard-wallet + payguard-loan + payguard-collateral + payguard-identity @@ -28,7 +28,7 @@ 4.1.1 1.68.0 3.25.5 - 3.19.15 + 3.21.7 0.6.1 1.7.1 @@ -92,6 +92,10 @@ src/*/java/**/*.java + + **/jooq/generated/**/*.java + + 1.30.0 @@ -155,7 +159,7 @@ 3.28.0 - 21 + ${java.version} true true From 90d5843875e90f573f1272d2858d884171aed135 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Sun, 1 Nov 2026 14:41:14 +0330 Subject: [PATCH 2/3] Resolved Docker & Ansible CI problem --- infrastructure/compose.operations.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/infrastructure/compose.operations.yaml b/infrastructure/compose.operations.yaml index 5a5c67b..0035952 100644 --- a/infrastructure/compose.operations.yaml +++ b/infrastructure/compose.operations.yaml @@ -446,6 +446,13 @@ services: PAYGUARD_LOCAL_GID: ${PAYGUARD_LOCAL_GID:-1000} PAYGUARD_DOCKER_GID: ${PAYGUARD_DOCKER_GID:-0} COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-payguard} + PAYGUARD_POSTGRES_IDENTITY_VOLUME: ${PAYGUARD_POSTGRES_IDENTITY_VOLUME:-payguard_postgres_identity_data} + PAYGUARD_POSTGRES_WALLET_VOLUME: ${PAYGUARD_POSTGRES_WALLET_VOLUME:-payguard_postgres_wallet_data} + PAYGUARD_POSTGRES_LOAN_VOLUME: ${PAYGUARD_POSTGRES_LOAN_VOLUME:-payguard_postgres_loan_data} + PAYGUARD_POSTGRES_COLLATERAL_VOLUME: ${PAYGUARD_POSTGRES_COLLATERAL_VOLUME:-payguard_postgres_collateral_data} + PAYGUARD_VAULT_DATA_VOLUME: ${PAYGUARD_VAULT_DATA_VOLUME:-payguard_vault_data} + PAYGUARD_VAULT_AUDIT_VOLUME: ${PAYGUARD_VAULT_AUDIT_VOLUME:-payguard_vault_audit} + PAYGUARD_KAFKA_DATA_VOLUME: ${PAYGUARD_KAFKA_DATA_VOLUME:-payguard_kafka_data} ANSIBLE_CONFIG: ${PAYGUARD_REPO_ROOT:-/workspace/payguard}/infrastructure/operations/ansible.cfg ANSIBLE_LOCAL_TEMP: /tmp/ansible ANSIBLE_REMOTE_TEMP: /tmp/ansible From 9aa56f80d12b6c3356f73c0b96fe45cf638e0318 Mon Sep 17 00:00:00 2001 From: Amir Golmoradi Date: Sun, 1 Nov 2026 19:51:51 +0330 Subject: [PATCH 3/3] =?UTF-8?q?Moved=20schemas=20and=20connectRetries=20in?= =?UTF-8?q?to=20Flyway=E2=80=99s=20default=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/compose.ide.yaml | 20 ++++++++++++++++++++ infrastructure/flyway/flyway.toml | 8 +++++--- infrastructure/operations/migrate.yml | 1 + infrastructure/vault/agent/collateral.hcl | 1 + infrastructure/vault/agent/identity.hcl | 1 + infrastructure/vault/agent/loan.hcl | 1 + infrastructure/vault/agent/wallet.hcl | 1 + 7 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 infrastructure/compose.ide.yaml diff --git a/infrastructure/compose.ide.yaml b/infrastructure/compose.ide.yaml new file mode 100644 index 0000000..4a6412b --- /dev/null +++ b/infrastructure/compose.ide.yaml @@ -0,0 +1,20 @@ +# Optional local IDE access; PostgreSQL remains private without this override. +services: + postgres-wallet: + networks: [database, ide] + ports: + - "127.0.0.1:${WALLET_POSTGRES_PORT:-15431}:5432" + postgres-loan: + networks: [database, ide] + ports: + - "127.0.0.1:${LOAN_POSTGRES_PORT:-15432}:5432" + postgres-collateral: + networks: [database, ide] + ports: + - "127.0.0.1:${COLLATERAL_POSTGRES_PORT:-15433}:5432" + postgres-identity: + networks: [database, ide] + ports: + - "127.0.0.1:${IDENTITY_POSTGRES_PORT:-15434}:5432" +networks: + ide: {} diff --git a/infrastructure/flyway/flyway.toml b/infrastructure/flyway/flyway.toml index af46b67..b736f37 100644 --- a/infrastructure/flyway/flyway.toml +++ b/infrastructure/flyway/flyway.toml @@ -1,14 +1,16 @@ [flyway] locations = ["filesystem:/flyway/sql", "filesystem:/flyway/callbacks"] defaultSchema = "payguard" -schemas = ["payguard"] createSchemas = false cleanDisabled = true baselineOnMigrate = false outOfOrder = false validateOnMigrate = true validateMigrationNaming = true -connectRetries = 3 lockRetryCount = 10 executeInTransaction = true -# Every connection selects the stable owner through afterConnect.sql. +# The Agent credential selects the stable owner at JDBC connection startup. + +[environments.default] +schemas = ["payguard"] +connectRetries = 3 diff --git a/infrastructure/operations/migrate.yml b/infrastructure/operations/migrate.yml index 2e1d30a..7bfa52c 100644 --- a/infrastructure/operations/migrate.yml +++ b/infrastructure/operations/migrate.yml @@ -118,6 +118,7 @@ ansible.builtin.assert: that: - rendered_text is search('(?m)^\[environments.default\]$') + - rendered_text is search('(?m)^jdbcProperties = \{ options = "-c role=' ~ service ~ '_owner" \}$') - rendered_text is search('(?m)^url = "jdbc:postgresql://postgres-' ~ service ~ ':5432/payguard_' ~ service ~ '\?sslmode=verify-full&sslrootcert=/run/agent-ca/ca.crt¤tSchema=payguard"$') - rendered_text is search('(?m)^user = ".+"$') - rendered_text is search('(?m)^password = ".+"$') diff --git a/infrastructure/vault/agent/collateral.hcl b/infrastructure/vault/agent/collateral.hcl index aa254a2..b9992f6 100644 --- a/infrastructure/vault/agent/collateral.hcl +++ b/infrastructure/vault/agent/collateral.hcl @@ -37,6 +37,7 @@ template { # lease_seconds={{ .LeaseDuration }} # lease_id={{ .LeaseID }} [environments.default] +jdbcProperties = { options = "-c role=collateral_owner" } url = "jdbc:postgresql://postgres-collateral:5432/payguard_collateral?sslmode=verify-full&sslrootcert=/run/agent-ca/ca.crt¤tSchema=payguard" user = {{ .Data.username | toJSON }} password = {{ .Data.password | toJSON }} diff --git a/infrastructure/vault/agent/identity.hcl b/infrastructure/vault/agent/identity.hcl index 4c43ccd..af1b97a 100644 --- a/infrastructure/vault/agent/identity.hcl +++ b/infrastructure/vault/agent/identity.hcl @@ -37,6 +37,7 @@ template { # lease_seconds={{ .LeaseDuration }} # lease_id={{ .LeaseID }} [environments.default] +jdbcProperties = { options = "-c role=identity_owner" } url = "jdbc:postgresql://postgres-identity:5432/payguard_identity?sslmode=verify-full&sslrootcert=/run/agent-ca/ca.crt¤tSchema=payguard" user = {{ .Data.username | toJSON }} password = {{ .Data.password | toJSON }} diff --git a/infrastructure/vault/agent/loan.hcl b/infrastructure/vault/agent/loan.hcl index 2e33a41..5243dbe 100644 --- a/infrastructure/vault/agent/loan.hcl +++ b/infrastructure/vault/agent/loan.hcl @@ -37,6 +37,7 @@ template { # lease_seconds={{ .LeaseDuration }} # lease_id={{ .LeaseID }} [environments.default] +jdbcProperties = { options = "-c role=loan_owner" } url = "jdbc:postgresql://postgres-loan:5432/payguard_loan?sslmode=verify-full&sslrootcert=/run/agent-ca/ca.crt¤tSchema=payguard" user = {{ .Data.username | toJSON }} password = {{ .Data.password | toJSON }} diff --git a/infrastructure/vault/agent/wallet.hcl b/infrastructure/vault/agent/wallet.hcl index e788ba3..fa4cb9e 100644 --- a/infrastructure/vault/agent/wallet.hcl +++ b/infrastructure/vault/agent/wallet.hcl @@ -37,6 +37,7 @@ template { # lease_seconds={{ .LeaseDuration }} # lease_id={{ .LeaseID }} [environments.default] +jdbcProperties = { options = "-c role=wallet_owner" } url = "jdbc:postgresql://postgres-wallet:5432/payguard_wallet?sslmode=verify-full&sslrootcert=/run/agent-ca/ca.crt¤tSchema=payguard" user = {{ .Data.username | toJSON }} password = {{ .Data.password | toJSON }}