diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java new file mode 100644 index 0000000..aa00943 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/CustomerId.java @@ -0,0 +1,40 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import domain.ValueObject; +import org.jspecify.annotations.Nullable; +import org.springframework.modulith.NamedInterface; + +@NamedInterface +public final class CustomerId implements ValueObject { + private static final String CUSTOMER_ID_PREFIX = "cus_"; + private final String value; + + private CustomerId(String value) { + this.value = PrefixedIdValidator.validateAndReturn(value, CUSTOMER_ID_PREFIX); + } + + public static CustomerId parse(String value) { + return new CustomerId(value); + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof CustomerId that)) return false; + + return getValue().equals(that.getValue()); + } + + @Override + public int hashCode() { + return getValue().hashCode(); + } + + public String getValue() { + return value; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherCustomerId) { + return equals(otherCustomerId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.java new file mode 100644 index 0000000..e4158a1 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/OtpId.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 OtpId implements ValueObject { + private static final String OTP_ID_PREFIX = "otp_"; + private final String value; + + private OtpId(String value) { + this.value = PrefixedIdValidator.validateAndReturn(value, OTP_ID_PREFIX); + } + + public static OtpId parse(String value) { + return new OtpId(value); + } + + public String value() { + return value; + } + + @Override + public boolean equals(@Nullable Object other) { + return this == other || (other instanceof OtpId that && value.equals(that.value)); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean sameValueAs(@Nullable ValueObject otherOtpId) { + return equals(otherOtpId); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java new file mode 100644 index 0000000..889fe0b --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/PrefixedIdValidator.java @@ -0,0 +1,33 @@ +package dev.amg.payguard.identity.customer.domain.value_object; + +import java.util.Objects; + +final class PrefixedIdValidator { + private static final int RANDOM_SUFFIX_LENGTH = 22; + private static final String BASE62_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + private PrefixedIdValidator() {} + + static String validateAndReturn(String identifier, String expectedPrefix) { + Objects.requireNonNull(identifier); + Objects.requireNonNull(expectedPrefix); + + if (identifier.length() != (expectedPrefix.length() + RANDOM_SUFFIX_LENGTH) + || !identifier.startsWith(expectedPrefix)) { + throw new IllegalArgumentException( + "invalid identifier format: expected " + + expectedPrefix + + " followed by 22 Base62 characters"); + } + + for (int index = expectedPrefix.length(); index < identifier.length(); index++) { + char character = identifier.charAt(index); + boolean isBase62Character = BASE62_ALPHABET.indexOf(character) >= 0; + if (!isBase62Character) { + throw new IllegalArgumentException("invalid identifier suffix: expected Base62 characters"); + } + } + return identifier; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java new file mode 100644 index 0000000..b00965e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/customer/domain/value_object/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.amg.payguard.identity.customer.domain.value_object; + +import org.jspecify.annotations.NullMarked; 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 new file mode 100644 index 0000000..9aa029d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpChannel.java @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..b0ea856 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpPurpose.java @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..f396626 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpStatus.java @@ -0,0 +1,9 @@ +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/enums/OtpVerificationMode.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java new file mode 100644 index 0000000..cc6b5a5 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationMode.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +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-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java new file mode 100644 index 0000000..17f8e57 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/enums/OtpVerificationResult.java @@ -0,0 +1,12 @@ +package dev.amg.payguard.identity.verification.domain.enums; + +public enum OtpVerificationResult { + VERIFIED, + INVALID_CODE, + EXPIRED, + ALREADY_USED, + LOCKED, + INVALIDATED, + PURPOSE_MISMATCH, + DEVICE_MISMATCH +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java new file mode 100644 index 0000000..b4f1df8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEvent.java @@ -0,0 +1,18 @@ +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 dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import java.time.Instant; + +public record OtpEvent( + String type, + OtpId challengeId, + CustomerId customerId, + OtpPurpose purpose, + OtpChannel channel, + OtpVerificationResult result, + Instant occurredAt, + String reason) {} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java new file mode 100644 index 0000000..85f01a1 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/event/OtpEventPublisher.java @@ -0,0 +1,6 @@ +package dev.amg.payguard.identity.verification.domain.event; + +@FunctionalInterface +public interface OtpEventPublisher { + void publish(OtpEvent event); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java new file mode 100644 index 0000000..95eb3cf --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/InvalidOtpAttemptLimitException.java @@ -0,0 +1,14 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class InvalidOtpAttemptLimitException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + private static final String MESSAGE = + "OTP max attempts must be greater than or equal to %d, but was %d."; + + public InvalidOtpAttemptLimitException(int maxAttempts, int minAttempts) { + super(MESSAGE.formatted(maxAttempts, minAttempts)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java new file mode 100644 index 0000000..16791d8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpAttemptCountOutOfRangeException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpAttemptCountOutOfRangeException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP attempts must be between 0 and %d, but was %d."; + + public OtpAttemptCountOutOfRangeException(int attempts, int maxAttempts) { + super(MESSAGE.formatted(maxAttempts, attempts)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java new file mode 100644 index 0000000..520eed8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashMustNotBeBlankException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpCodeHashMustNotBeBlankException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP code hash must not be blank."; + + public OtpCodeHashMustNotBeBlankException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java new file mode 100644 index 0000000..1c4a65e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpCodeHashRequiredForRandomNumericVerificationException.java @@ -0,0 +1,16 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import java.io.Serial; + +public final class OtpCodeHashRequiredForRandomNumericVerificationException + extends RuntimeException { + @Serial private static final long serialVersionUID = 1L; + + public static final String MESSAGE = "OTP code hash is required for %s verification mode."; + + public OtpCodeHashRequiredForRandomNumericVerificationException( + OtpVerificationMode verificationMode) { + super(MESSAGE.formatted(verificationMode)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java new file mode 100644 index 0000000..f3e7d89 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeMustMatchUsedStatusException.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public final class OtpConsumedTimeMustMatchUsedStatusException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = + """ + OTP consumption time must be within the challenge validity period. \ + consumedAt=%s, createdAt=%s, expiresAt=%s."""; + + public OtpConsumedTimeMustMatchUsedStatusException( + Instant consumedAt, Instant createdAt, Instant expiresAt) { + super(MESSAGE.formatted(consumedAt, createdAt, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java new file mode 100644 index 0000000..0e8a8f4 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpConsumedTimeOutsideValidityPeriodException.java @@ -0,0 +1,18 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public final class OtpConsumedTimeOutsideValidityPeriodException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = + """ + OTP consumption time must be within the validity period. \ + consumedAt=%s, createdAt=%s, expiresAt=%s"""; + + public OtpConsumedTimeOutsideValidityPeriodException( + Instant consumedAt, Instant createdAt, Instant expiresAt) { + super(MESSAGE.formatted(consumedAt, createdAt, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java new file mode 100644 index 0000000..573e48e --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpDeviceBindingHashMustNotBeBlankException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpDeviceBindingHashMustNotBeBlankException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + public static final String MESSAGE = "OTP device binding hash must not be blank."; + + public OtpDeviceBindingHashMustNotBeBlankException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java new file mode 100644 index 0000000..99dfbd8 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpExpirationTimeMustBeAfterCreationTimeException.java @@ -0,0 +1,13 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; + +public final class OtpExpirationTimeMustBeAfterCreationTimeException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + private static final String MESSAGE = "OTP expiration time must be after the creation time."; + + public OtpExpirationTimeMustBeAfterCreationTimeException() { + super(MESSAGE); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java new file mode 100644 index 0000000..ef4a593 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/exception/OtpRetentionPeriodMustNotPrecedeExpirationException.java @@ -0,0 +1,19 @@ +package dev.amg.payguard.identity.verification.domain.exception; + +import java.io.Serial; +import java.time.Instant; + +public class OtpRetentionPeriodMustNotPrecedeExpirationException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + 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. " + + "retentionUntil=%s, expiresAt=%s"; + + public OtpRetentionPeriodMustNotPrecedeExpirationException( + Instant retentionUntil, Instant expiresAt) { + super(MESSAGE.formatted(retentionUntil, expiresAt)); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java new file mode 100644 index 0000000..9998b96 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OTP.java @@ -0,0 +1,154 @@ +package dev.amg.payguard.identity.verification.domain.model; + +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.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.util.Objects; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** + * An OTP challenge associated with a PayGuard customer. + * + *
Owns the challenge's verification and invalidation behavior. State changes replace the current + * {@link OtpSnapshot} without modifying previously created snapshots. + * + *
Issuance requires an ACTIVE snapshot with zero recorded attempts and no consumption time. + * Restoration accepts an existing validated snapshot without resetting its status, attempt count, + * or timestamps. + * + *
The verification policy checks for rejection before the code is checked. Rejected requests do + * not increase the attempt count. If the policy reports expiration for an ACTIVE challenge, its + * status changes to EXPIRED. + * + *
A failed code check records one attempt. The last allowed failed attempt also changes the + * status to LOCKED in the same new snapshot. + * + *
A successful code check records one attempt, changes the status to USED, and sets the + * consumption time to the time supplied by the command. A correct code is therefore accepted on the + * last allowed attempt. + * + *
For TOTP verification, this aggregate uses the verification result supplied by the command; it + * does not validate the TOTP itself. For other verification modes, it compares the submitted code + * with the stored hash through {@link + * dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher}. + * + *
Invalidation changes an ACTIVE challenge to INVALIDATED. Challenges in other states remain + * unchanged. + * + *
A VERIFIED result reports successful completion of this challenge. It does not establish
+ * permission to access a wallet or execute a financial operation. The consuming application must
+ * enforce those permissions and associate the result with the intended operation.
+ *
+ * @implSpec This aggregate is mutable and does not synchronize its operations. Concurrent mutations
+ * and persistence updates require coordination outside this class. Replacing an immutable
+ * snapshot does not make verification thread-safe.
+ */
+public final class OTP extends AggregateRoot Contains the verification settings, attempt count, status, and lifecycle timestamps. Changes
+ * produce a new validated snapshot without modifying the existing one.
+ *
+ * The maximum number of attempts must be positive. The recorded attempt count must be between
+ * zero and that maximum, inclusive. An ACTIVE snapshot must have at least one attempt remaining.
+ *
+ * Both successful and failed code checks count as attempts. Recording the last failed attempt
+ * and setting the status to LOCKED must happen in the same new snapshot to avoid an invalid
+ * intermediate state.
+ *
+ * RANDOM_NUMERIC verification requires a nonblank code hash. The device-binding hash is
+ * optional. Neither hash may be blank when present.
+ *
+ * Expiration must be strictly after creation. A consumption time must be present exactly when
+ * the status is USED.
+ *
+ * Consumption is allowed at creation time, but not at or after expiration: {@code createdAt <=
+ * consumedAt < expiresAt}.
+ *
+ * Retention constraints are checked by {@link OtpRetentionPolicy}. Retention does not extend the
+ * challenge's verification validity.
+ *
+ * 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
+ * verified.
+ *
+ * @implSpec Every constructor call validates the supplied state. The factory and builder use the
+ * same constructor validation. All components are immutable.
+ */
+public record OtpSnapshot(
+ OtpPurpose purpose,
+ OtpChannel channel,
+ OtpVerificationMode verificationMode,
+ @Nullable String codeHash,
+ @Nullable String deviceBindingHash,
+ int maxAttempts,
+ Instant createdAt,
+ Instant expiresAt,
+ Instant retentionUntil,
+ int attempts,
+ OtpStatus status,
+ @Nullable Instant consumedAt) {
+ private static final int MIN_ATTEMPTS = 1;
+
+ public OtpSnapshot {
+ validateRequiredFields(
+ purpose, channel, verificationMode, createdAt, expiresAt, retentionUntil, status);
+ validateAttempts(status, attempts, maxAttempts);
+ validateVerificationData(verificationMode, codeHash, deviceBindingHash);
+ validateLifecycle(status, createdAt, expiresAt, consumedAt);
+ OtpRetentionPolicy.validate(expiresAt, retentionUntil);
+ }
+
+ /**
+ * Creates a validated snapshot. The supplied state does not have to represent a newly issued OTP.
+ */
+ public static OtpSnapshot create(
+ OtpPurpose purpose,
+ OtpChannel channel,
+ OtpVerificationMode verificationMode,
+ @Nullable String codeHash,
+ @Nullable String deviceBindingHash,
+ int maxAttempts,
+ Instant createdAt,
+ Instant expiresAt,
+ Instant retentionUntil,
+ int attempts,
+ OtpStatus status,
+ @Nullable Instant consumedAt) {
+ return new OtpSnapshot(
+ purpose,
+ channel,
+ verificationMode,
+ codeHash,
+ deviceBindingHash,
+ maxAttempts,
+ createdAt,
+ expiresAt,
+ retentionUntil,
+ attempts,
+ status,
+ consumedAt);
+ }
+
+ private static void validateRequiredFields(
+ OtpPurpose purpose,
+ OtpChannel channel,
+ OtpVerificationMode verificationMode,
+ Instant createdAt,
+ Instant expiresAt,
+ Instant retentionUntil,
+ OtpStatus status) {
+
+ Objects.requireNonNull(purpose, "purpose must not be null");
+ Objects.requireNonNull(channel, "channel must not be null");
+ Objects.requireNonNull(verificationMode, "verificationMode must not be null");
+ Objects.requireNonNull(createdAt, "createdAt must not be null");
+ Objects.requireNonNull(expiresAt, "expiresAt must not be null");
+ Objects.requireNonNull(retentionUntil, "retentionUntil must not be null");
+ Objects.requireNonNull(status, "status must not be null");
+ }
+
+ private static void validateAttempts(OtpStatus status, int attempts, int maxAttempts) {
+ if (maxAttempts < MIN_ATTEMPTS) {
+ throw new InvalidOtpAttemptLimitException(maxAttempts, MIN_ATTEMPTS);
+ }
+
+ 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);
+ }
+ }
+
+ private static void validateVerificationData(
+ OtpVerificationMode verificationMode,
+ @Nullable String codeHash,
+ @Nullable String deviceBindingHash) {
+ if (verificationMode == OtpVerificationMode.RANDOM_NUMERIC
+ && (codeHash == null || codeHash.isBlank())) {
+ throw new OtpCodeHashRequiredForRandomNumericVerificationException(verificationMode);
+ }
+
+ if (codeHash != null && codeHash.isBlank()) {
+ throw new OtpCodeHashMustNotBeBlankException();
+ }
+
+ /* Null means no device binding; a supplied hash must not be blank. */
+ if (deviceBindingHash != null && deviceBindingHash.isBlank()) {
+ throw new OtpDeviceBindingHashMustNotBeBlankException();
+ }
+ }
+
+ private static void validateLifecycle(
+ OtpStatus status, Instant createdAt, Instant expiresAt, @Nullable Instant consumedAt) {
+ if (!expiresAt.isAfter(createdAt)) {
+ throw new OtpExpirationTimeMustBeAfterCreationTimeException();
+ }
+
+ final boolean isUsed = status == OtpStatus.USED;
+ final boolean hasConsumedAt = consumedAt != null;
+
+ if (isUsed != hasConsumedAt) {
+ throw new OtpConsumedTimeMustMatchUsedStatusException(consumedAt, createdAt, expiresAt);
+ }
+ if (consumedAt != null && (consumedAt.isBefore(createdAt) || !consumedAt.isBefore(expiresAt))) {
+ throw new OtpConsumedTimeOutsideValidityPeriodException(consumedAt, createdAt, expiresAt);
+ }
+ }
+
+ /* Fluent builder methods deliberately match their state fields. */
+ @SuppressWarnings("PMD.AvoidFieldNameMatchingMethodName")
+ static final class Builder {
+
+ private OtpPurpose purpose;
+ private OtpChannel channel;
+ private OtpVerificationMode verificationMode;
+ @Nullable private String codeHash;
+ @Nullable private String deviceBindingHash;
+ private Instant createdAt;
+ private Instant expiresAt;
+ private Instant retentionUntil;
+ private OtpStatus status;
+ @Nullable private Instant consumedAt;
+ private int maxAttempts;
+ private int attempts;
+
+ private Builder(OtpSnapshot snapshot) {
+ this.purpose = snapshot.purpose();
+ this.channel = snapshot.channel();
+ this.verificationMode = snapshot.verificationMode();
+ this.codeHash = snapshot.codeHash();
+ this.deviceBindingHash = snapshot.deviceBindingHash();
+ this.createdAt = snapshot.createdAt();
+ this.expiresAt = snapshot.expiresAt();
+ this.retentionUntil = snapshot.retentionUntil();
+ this.status = snapshot.status();
+ this.consumedAt = snapshot.consumedAt();
+ this.maxAttempts = snapshot.maxAttempts();
+ this.attempts = snapshot.attempts();
+ }
+
+ Builder purpose(OtpPurpose purpose) {
+ this.purpose = purpose;
+ return this;
+ }
+
+ Builder channel(OtpChannel channel) {
+ this.channel = channel;
+ return this;
+ }
+
+ Builder verificationMode(OtpVerificationMode mode) {
+ this.verificationMode = mode;
+ return this;
+ }
+
+ Builder codeHash(@Nullable String codeHash) {
+ this.codeHash = codeHash;
+ return this;
+ }
+
+ Builder deviceBindingHash(@Nullable String hash) {
+ this.deviceBindingHash = hash;
+ return this;
+ }
+
+ Builder maxAttempts(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ return this;
+ }
+
+ Builder createdAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ return this;
+ }
+
+ Builder expiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ return this;
+ }
+
+ Builder retentionUntil(Instant retentionUntil) {
+ this.retentionUntil = retentionUntil;
+ return this;
+ }
+
+ Builder attempts(int attempts) {
+ this.attempts = attempts;
+ return this;
+ }
+
+ Builder status(OtpStatus status) {
+ this.status = status;
+ return this;
+ }
+
+ Builder consumedAt(@Nullable Instant consumedAt) {
+ this.consumedAt = consumedAt;
+ return this;
+ }
+
+ OtpSnapshot build() {
+ return new OtpSnapshot(
+ purpose,
+ channel,
+ verificationMode,
+ codeHash,
+ deviceBindingHash,
+ maxAttempts,
+ createdAt,
+ expiresAt,
+ retentionUntil,
+ attempts,
+ status,
+ consumedAt);
+ }
+ }
+
+ OtpSnapshot withStatus(OtpStatus newStatus) {
+ return toBuilder().status(newStatus).build();
+ }
+
+ /**
+ * Records one attempt without changing the status. Use withFailedAttemptAndLock for the last
+ * failed attempt.
+ */
+ OtpSnapshot withAttemptRecorded() {
+ return toBuilder().attempts(attempts + 1).build();
+ }
+
+ /**
+ * Records a failed attempt and locks the OTP in the same snapshot. This avoids creating an ACTIVE
+ * snapshot with no attempts remaining.
+ */
+ OtpSnapshot withFailedAttemptAndLock() {
+ return toBuilder().attempts(attempts + 1).status(OtpStatus.LOCKED).build();
+ }
+
+ /**
+ * Records a successful attempt, marks the OTP as USED, and sets its consumption time in the same
+ * snapshot. Successful attempts also count toward the attempt limit.
+ */
+ OtpSnapshot withSuccessfulAttempt(Instant verifiedAt) {
+ return toBuilder().attempts(attempts + 1).status(OtpStatus.USED).consumedAt(verifiedAt).build();
+ }
+
+ @Override
+ public String toString() {
+ return "OtpSnapshot[purpose="
+ + purpose
+ + ", status="
+ + status
+ + ", attempts="
+ + attempts
+ + ", codeHash=[REDACTED], deviceBindingHash=[REDACTED]]";
+ }
+
+ Builder toBuilder() {
+ return new Builder(this);
+ }
+}
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java
new file mode 100644
index 0000000..35d747f
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/package-info.java
@@ -0,0 +1,4 @@
+@NullMarked
+package dev.amg.payguard.identity.verification.domain.model;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java
new file mode 100644
index 0000000..c79d563
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpRetentionPolicy.java
@@ -0,0 +1,23 @@
+package dev.amg.payguard.identity.verification.domain.policy;
+
+import dev.amg.payguard.identity.verification.domain.exception.OtpRetentionPeriodMustNotPrecedeExpirationException;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+public final class OtpRetentionPolicy {
+ private OtpRetentionPolicy() {
+ /* This utility class should not be instantiated */
+ }
+
+ private static final int MIN_RETENTION_YEARS = 3;
+
+ private static Instant minimumRetentionUntil(Instant expiresAt) {
+ return expiresAt.atZone(ZoneOffset.UTC).plusYears(MIN_RETENTION_YEARS).toInstant();
+ }
+
+ public static void validate(Instant expiresAt, Instant retentionUntil) {
+ if (retentionUntil.isBefore(minimumRetentionUntil(expiresAt))) {
+ throw new OtpRetentionPeriodMustNotPrecedeExpirationException(retentionUntil, expiresAt);
+ }
+ }
+}
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
new file mode 100644
index 0000000..cf1ef52
--- /dev/null
+++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/policy/OtpVerificationPolicy.java
@@ -0,0 +1,43 @@
+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 OptionalAttempt limits
+ *
+ * Verification data
+ *
+ * Validity and consumption
+ *
+ * Responsibility
+ *
+ *