|null $customer_draft Unverified customer fields. * @param int|null $cart_id Linked cart FK. * @param string|null $coupon_code Applied coupon code. * @param array|null $totals Server-side totals JSON. * @param string|null $current_step Current step key. * @param list|null $completed_steps Completed step keys. * @param string|null $locale Locale. * @param string|null $customer_timezone Customer IANA zone. * @param string|null $ip_hash Hashed IP. * @param string|null $user_agent_hash Hashed user agent. * @param string|null $referrer Referrer URL. * @param DateTimeImmutable|null $expires_at Soft expiry UTC. * @param int|null $converted_order_id Resulting order FK. * @param DateTimeImmutable $last_activity_at Last mutation UTC. * @param DateTimeImmutable $created_at Created UTC. * @param DateTimeImmutable $updated_at Updated UTC. * * @throws DomainException When invariants fail. */ public function __construct( private ?int $id, private Uuid $uuid, private string $token_hash, private ?int $form_id, private OrderIntentStatus $status, private ?array $customer_draft, private ?int $cart_id, private ?string $coupon_code, private ?array $totals, private ?string $current_step, private ?array $completed_steps, private ?string $locale, private ?string $customer_timezone, private ?string $ip_hash, private ?string $user_agent_hash, private ?string $referrer, private ?DateTimeImmutable $expires_at, private ?int $converted_order_id, private DateTimeImmutable $last_activity_at, private DateTimeImmutable $created_at, private DateTimeImmutable $updated_at, ) { if ( 64 !== strlen( $this->token_hash ) || 1 !== preg_match( '/^[a-f0-9]{64}$/', $this->token_hash ) ) { throw new DomainException( 'order_intent.token_hash', 'token_hash must be a 64-character hex digest.' ); } if ( null !== $this->form_id && $this->form_id < 1 ) { throw new DomainException( 'order_intent.form_id', 'form_id must be positive when set.' ); } if ( null !== $this->cart_id && $this->cart_id < 1 ) { throw new DomainException( 'order_intent.cart_id', 'cart_id must be positive when set.' ); } if ( null !== $this->converted_order_id && $this->converted_order_id < 1 ) { throw new DomainException( 'order_intent.converted_order_id', 'converted_order_id must be positive when set.' ); } } /** * Database id. * * @since 0.1.0 */ public function id(): ?int { return $this->id; } /** * Public UUID. * * @since 0.1.0 */ public function uuid(): Uuid { return $this->uuid; } /** * Token hash. * * @since 0.1.0 */ public function token_hash(): string { return $this->token_hash; } /** * Form id. * * @since 0.1.0 */ public function form_id(): ?int { return $this->form_id; } /** * Status. * * @since 0.1.0 */ public function status(): OrderIntentStatus { return $this->status; } /** * Customer draft payload. * * @return array|null * * @since 0.1.0 */ public function customer_draft(): ?array { return $this->customer_draft; } /** * Cart id. * * @since 0.1.0 */ public function cart_id(): ?int { return $this->cart_id; } /** * Coupon code. * * @since 0.1.0 */ public function coupon_code(): ?string { return $this->coupon_code; } /** * Totals JSON. * * @return array|null * * @since 0.1.0 */ public function totals(): ?array { return $this->totals; } /** * Current step key. * * @since 0.1.0 */ public function current_step(): ?string { return $this->current_step; } /** * Completed steps. * * @return list|null * * @since 0.1.0 */ public function completed_steps(): ?array { return $this->completed_steps; } /** * Locale. * * @since 0.1.0 */ public function locale(): ?string { return $this->locale; } /** * Customer timezone. * * @since 0.1.0 */ public function customer_timezone(): ?string { return $this->customer_timezone; } /** * IP hash. * * @since 0.1.0 */ public function ip_hash(): ?string { return $this->ip_hash; } /** * User agent hash. * * @since 0.1.0 */ public function user_agent_hash(): ?string { return $this->user_agent_hash; } /** * Referrer. * * @since 0.1.0 */ public function referrer(): ?string { return $this->referrer; } /** * Expires at. * * @since 0.1.0 */ public function expires_at(): ?DateTimeImmutable { return $this->expires_at; } /** * Converted order id. * * @since 0.1.0 */ public function converted_order_id(): ?int { return $this->converted_order_id; } /** * Last activity. * * @since 0.1.0 */ public function last_activity_at(): DateTimeImmutable { return $this->last_activity_at; } /** * Created at. * * @since 0.1.0 */ public function created_at(): DateTimeImmutable { return $this->created_at; } /** * Updated at. * * @since 0.1.0 */ public function updated_at(): DateTimeImmutable { return $this->updated_at; } /** * Assign the database id after insert. * * @param int $id New id. * * @throws DomainException When already assigned. * * @since 0.1.0 */ public function assign_id( int $id ): void { if ( null !== $this->id ) { throw new DomainException( 'order_intent.id_assigned', 'Order intent id is already assigned.' ); } if ( $id < 1 ) { throw new DomainException( 'order_intent.id_invalid', 'Order intent id must be positive.' ); } $this->id = $id; } /** * Link the cart after creation. * * @param int $cart_id Cart id. * @param DateTimeImmutable $now Instant. * * @throws DomainException When cart id is invalid. * * @since 0.1.0 */ public function link_cart( int $cart_id, DateTimeImmutable $now ): void { if ( $cart_id < 1 ) { throw new DomainException( 'order_intent.cart_id', 'cart_id must be positive when set.' ); } $this->cart_id = $cart_id; $this->touch( $now ); } /** * Apply a mutation while the intent is still open. * * @param array|null $customer_draft Draft fields. * @param string|null $coupon_code Coupon. * @param string|null $current_step Step key. * @param list|null $completed_steps Completed keys. * @param DateTimeImmutable $now Instant. * * @throws DomainException When not open. * * @since 0.1.0 */ public function mutate( ?array $customer_draft, ?string $coupon_code, ?string $current_step, ?array $completed_steps, DateTimeImmutable $now ): void { $this->assert_open_for_mutation(); if ( null !== $customer_draft ) { $this->customer_draft = $customer_draft; } if ( null !== $coupon_code ) { $trimmed = trim( $coupon_code ); $this->coupon_code = '' === $trimmed ? null : $trimmed; } if ( null !== $current_step ) { $this->current_step = $current_step; } if ( null !== $completed_steps ) { $this->completed_steps = array_values( $completed_steps ); } $this->touch( $now ); } /** * Replace server-computed totals. * * @param array $totals Totals payload. * @param DateTimeImmutable $now Instant. * * @throws DomainException When not open. * * @since 0.1.0 */ public function replace_totals( array $totals, DateTimeImmutable $now ): void { $this->assert_open_for_mutation(); $this->totals = $totals; $this->touch( $now ); } /** * Mark converting (caller must have won the compare-and-set). * * @param DateTimeImmutable $now Instant. * * @throws DomainException When status is not open. * * @since 0.1.0 */ public function mark_converting( DateTimeImmutable $now ): void { if ( OrderIntentStatus::Open !== $this->status ) { throw new DomainException( 'order_intent.not_open', 'Only an open intent may begin conversion.' ); } $this->status = OrderIntentStatus::Converting; $this->touch( $now ); } /** * Mark converted with the resulting order id. * * @param int $order_id Order id. * @param DateTimeImmutable $now Instant. * * @throws DomainException When not converting or order id invalid. * * @since 0.1.0 */ public function mark_converted( int $order_id, DateTimeImmutable $now ): void { if ( OrderIntentStatus::Converting !== $this->status ) { throw new DomainException( 'order_intent.not_converting', 'Only a converting intent may be marked converted.' ); } if ( $order_id < 1 ) { throw new DomainException( 'order_intent.converted_order_id', 'converted_order_id must be positive when set.' ); } $this->converted_order_id = $order_id; $this->status = OrderIntentStatus::Converted; $this->touch( $now ); } /** * Mark abandoned. * * @param DateTimeImmutable $now Instant. * * @throws DomainException When status cannot abandon. * * @since 0.1.0 */ public function mark_abandoned( DateTimeImmutable $now ): void { if ( OrderIntentStatus::Open !== $this->status ) { throw new DomainException( 'order_intent.abandon_blocked', 'Only an open intent may be abandoned.' ); } $this->status = OrderIntentStatus::Abandoned; $this->touch( $now ); } /** * Re-open an abandoned intent so a recovery link can resume the flow. * * @param DateTimeImmutable $now Instant. * * @throws DomainException When not abandoned. * * @since 0.1.0 */ public function reopen_abandoned( DateTimeImmutable $now ): void { if ( OrderIntentStatus::Abandoned !== $this->status ) { throw new DomainException( 'order_intent.reopen_blocked', 'Only an abandoned intent may be reopened.' ); } $this->status = OrderIntentStatus::Open; $this->expires_at = $now->setTimezone( new DateTimeZone( 'UTC' ) )->modify( '+24 hours' ); $this->touch( $now ); } /** * Replace the session token hash (plaintext is returned once to the caller). * * @param string $token_hash SHA-256 hex digest. * @param DateTimeImmutable $now Instant. * * @throws DomainException When the hash is invalid. * * @since 0.1.0 */ public function rotate_token_hash( string $token_hash, DateTimeImmutable $now ): void { if ( 64 !== strlen( $token_hash ) || 1 !== preg_match( '/^[a-f0-9]{64}$/', $token_hash ) ) { throw new DomainException( 'order_intent.token_hash', 'token_hash must be a 64-character hex digest.' ); } $this->token_hash = $token_hash; $this->touch( $now ); } /** * Mark failed (stuck conversion). * * @param DateTimeImmutable $now Instant. * * @throws DomainException When not converting. * * @since 0.1.0 */ public function mark_failed( DateTimeImmutable $now ): void { if ( OrderIntentStatus::Converting !== $this->status ) { throw new DomainException( 'order_intent.fail_blocked', 'Only a converting intent may be marked failed.' ); } $this->status = OrderIntentStatus::Failed; $this->touch( $now ); } /** * Clear personal draft fields for GDPR erasure. * * @param DateTimeImmutable $now Instant. * * @since 0.1.0 */ public function erase_personal_draft( DateTimeImmutable $now ): void { $this->customer_draft = null; $this->ip_hash = null; $this->user_agent_hash = null; $this->referrer = null; $this->touch( $now ); } /** * Email from customer draft when present. * * @since 0.1.0 */ public function draft_email(): ?string { if ( null === $this->customer_draft || ! isset( $this->customer_draft['email'] ) ) { return null; } $email = $this->customer_draft['email']; return is_string( $email ) && '' !== $email ? strtolower( trim( $email ) ) : null; } /** * Bump activity timestamps. * * @param DateTimeImmutable $now Instant. */ private function touch( DateTimeImmutable $now ): void { $utc = $now->setTimezone( new DateTimeZone( 'UTC' ) ); $this->last_activity_at = $utc; $this->updated_at = $utc; } /** * Guard mutations. * * @throws DomainException When not open. */ private function assert_open_for_mutation(): void { if ( OrderIntentStatus::Open !== $this->status ) { throw new DomainException( 'order_intent.not_open', 'Only an open intent may be mutated.' ); } } }