*/ private const VALID_STATUSES = array( self::STATUS_IN_STOCK, self::STATUS_OUT_OF_STOCK, self::STATUS_LIMITED, self::STATUS_PREORDER, self::STATUS_UNKNOWN, self::STATUS_UNAVAILABLE, ); /** * One of the {@see self::STATUS_*} tokens. Validated at * construction time so an out-of-set value never reaches the * rendering layer. */ private string $status; /** * Provider-supplied human-readable label, or null when the * provider response did not carry one. */ private ?string $label; /** * Whether the rendering layer should enable a "buy" affordance * for this product. Independent of {@see self::$status} per the * §7.6 contract; see the file-level docblock. */ private bool $isPurchasable; /** * Construct an availability snapshot with explicit values for * every field. * * The constructor is exhaustive so a programming error (missing * purchasable flag, swapped status / label) surfaces at the type * system boundary rather than at the rendering layer. * * Validation: * * - `status` is trimmed and matched against the closed set in * {@see self::VALID_STATUSES}; any other token raises * {@see \InvalidArgumentException} carrying the offending * value verbatim. * - `label`, when non-null, is trimmed and rejected when the * trim leaves an empty string — "present but empty" is * treated as a programming error. * * @param string $status One of {@see self::STATUS_*}. * @param string|null $label Provider label, or null. * @param bool $isPurchasable Whether the rendering layer * should enable a buy affordance. * * @throws InvalidArgumentException When `$status` is not a member * of the §7.6 closed set, or * when `$label` is the empty * string after trimming. */ public function __construct( string $status, ?string $label, bool $isPurchasable ) { $this->status = self::normalizeStatus( $status ); $this->label = self::normalizeOptional( 'label', $label ); $this->isPurchasable = $isPurchasable; } /** * Construct an `unknown` snapshot — the canonical fallback for * provider responses that did not carry any availability signal. * * The §7.6 contract pairs `unknown` with no buy affordance: the * rendering layer cannot honestly enable checkout when the * provider has not confirmed the product is purchasable, so the * helper hard-codes `isPurchasable = false`. Callers that want * to override either field should construct the DTO directly. */ public static function unknown(): self { return new self( self::STATUS_UNKNOWN, null, false ); } /** * Closed set of accepted status tokens, in declaration order. * * Exposed for tests and for diagnostics surfaces that want to * enumerate the valid tokens (for example, populating an admin * filter dropdown) without duplicating the list. * * @return list */ public static function validStatuses(): array { return self::VALID_STATUSES; } /** * One of the {@see self::STATUS_*} tokens. */ public function status(): string { return $this->status; } /** * Provider-supplied label, or null. */ public function label(): ?string { return $this->label; } /** * Whether the rendering layer should enable a "buy" affordance. */ public function isPurchasable(): bool { return $this->isPurchasable; } /** * Convenience guard: report whether the status equals * {@see self::STATUS_IN_STOCK}. * * Exposed because the §7.6 status set has a single dominant * "happy path" token that consuming surfaces routinely branch * on; a dedicated helper keeps those branches readable without * forcing every call site to compare against the raw string * constant. */ public function isInStock(): bool { return $this->status === self::STATUS_IN_STOCK; } /** * Validate and normalize the `status` field at construction. * * The status is trimmed first so a carelessly-decoded payload * (trailing newline, padded space) is normalized to the same * canonical form a freshly-constructed status would carry. The * trimmed value MUST appear in {@see self::VALID_STATUSES}; * anything else raises {@see InvalidArgumentException} carrying * the offending input verbatim and a comma-separated list of * the accepted tokens for diagnosis. * * @param string $status Raw status as supplied by the caller. * * @return string Canonicalized status. * * @throws InvalidArgumentException When `$status` is not a * member of the §7.6 closed * set. */ private static function normalizeStatus( string $status ): string { $trimmed = trim( $status ); if ( ! in_array( $trimmed, self::VALID_STATUSES, true ) ) { $message = sprintf( 'AvailabilityInfo status must be one of [%s]; got "%s".', implode( ', ', self::VALID_STATUSES ), $status ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * Used for the `label` field 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( 'AvailabilityInfo %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; } }