amount = self::normalizeAmount( $amount ); $this->currency = self::normalizeCurrency( $currency ); $this->label = self::normalizeOptional( 'label', $label ); $this->observedAt = self::normalizeToUtc( $observedAt ); $this->isTaxIncluded = $isTaxIncluded; $this->disclaimerRequired = $disclaimerRequired; $this->disclaimerText = self::normalizeOptional( 'disclaimerText', $disclaimerText ); } /** * Decimal price amount as a stable string. */ public function amount(): string { return $this->amount; } /** * ISO 4217 currency code in uppercase, or null when unknown. */ public function currency(): ?string { return $this->currency; } /** * Provider-supplied label, or null. */ public function label(): ?string { return $this->label; } /** * UTC observation timestamp. */ public function observedAt(): DateTimeImmutable { return $this->observedAt; } /** * Whether `amount` already includes locally-applicable tax. */ public function isTaxIncluded(): bool { return $this->isTaxIncluded; } /** * Whether the rendering layer MUST display a disclaimer * alongside this price. */ public function disclaimerRequired(): bool { return $this->disclaimerRequired; } /** * Provider-supplied disclaimer body, or null when the rendering * layer should fall back to a AFFILUN-canonical message. */ public function disclaimerText(): ?string { return $this->disclaimerText; } /** * Format the observation timestamp as a canonical MySQL * `DATETIME` string in UTC. * * Exposed as a public helper so REST mappers, render pipelines, * and persistence-layer adapters can produce a stable string * representation without re-deriving the format. * * @param DateTimeInterface|null $value Timestamp to format, or null. * * @return string|null `Y-m-d H:i:s` UTC string, or null when * `$value` is null. */ public static function formatDateTime( ?DateTimeInterface $value ): ?string { if ( $value === null ) { return null; } $utc = $value instanceof DateTimeImmutable ? $value->setTimezone( new DateTimeZone( 'UTC' ) ) : DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) ); return $utc->format( 'Y-m-d H:i:s' ); } /** * Validate and normalize the `amount` field at construction. * * Accepts a decimal-string representation of a non-negative * finite number. The check is deliberately conservative: * * - The string is trimmed first; a fully-empty string after * trimming is rejected. * - A leading `+` sign is allowed and dropped on storage. * - Optional fractional part separated by a literal `.` is * allowed; thousands separators and locale-specific commas * are not — providers MUST normalize before constructing * this DTO, per §2.3. * - Scientific notation (`1.98e3`), hex / octal / binary * literals, embedded whitespace, NaN, and negative numbers * are rejected. * * @param string $amount Raw amount as supplied by the caller. * * @return string Canonicalized decimal-string representation. * * @throws InvalidArgumentException When `$amount` does not match * the accepted decimal grammar. */ private static function normalizeAmount( string $amount ): string { $trimmed = trim( $amount ); if ( $trimmed === '' ) { $message = 'PriceInfo amount must be a non-empty decimal string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } // Strip a single leading '+' for ergonomic acceptance of // '+1980' as equivalent to '1980'; minus signs are NOT // accepted because §7.5 prices are non-negative. if ( $trimmed[0] === '+' ) { $trimmed = substr( $trimmed, 1 ); } if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) { $message = sprintf( 'PriceInfo amount must be a non-negative decimal string; got "%s".', $amount ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `currency` field at construction. * * Null passes through unchanged. A non-null value is trimmed * and uppercased; an empty result is rejected. * * @param string|null $currency Raw currency code, or null. * * @return string|null Uppercased ISO 4217 code, or null. * * @throws InvalidArgumentException When `$currency` is the * empty string after trimming. */ private static function normalizeCurrency( ?string $currency ): ?string { if ( $currency === null ) { return null; } $trimmed = trim( $currency ); if ( $trimmed === '' ) { $message = 'PriceInfo currency must be null or a non-empty ISO 4217 code.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return strtoupper( $trimmed ); } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for the `label` and `disclaimerText` fields where * "present but empty" is a programming error rather than a * meaningful state. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @return string|null Trimmed value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptional( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'PriceInfo %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Normalize a timestamp to UTC so equality checks and * round-trips do not depend on the caller's timezone. * * @param DateTimeImmutable $value Timestamp to normalize. */ private static function normalizeToUtc( DateTimeImmutable $value ): DateTimeImmutable { return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } }