clock->now(); $token = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $token ); $intent = new OrderIntent( null, Uuid::generate(), $hash, $form_id, OrderIntentStatus::Open, null, null, null, null, null, null, $locale, $customer_timezone, null === $ip || '' === $ip ? null : hash( 'sha256', $ip ), null === $user_agent || '' === $user_agent ? null : hash( 'sha256', $user_agent ), $referrer, $now->add( new DateInterval( 'PT24H' ) ), null, $now, $now, $now, ); return $this->tx->run( function () use ( $intent, $token, $now ): CreatedOrderIntent { $this->intents->save( $intent ); $intent_id = (int) $intent->id(); $cart = new Cart( null, Uuid::generate(), $intent_id, CartStatus::Open, $now, $now, ); $this->carts->save( $cart ); $intent->link_cart( (int) $cart->id(), $now ); $this->intents->save( $intent ); return new CreatedOrderIntent( $token, $intent, $cart ); } ); } /** * Load a resumable snapshot by plaintext token. * * @param string $token Session token. * * @throws DomainException When unknown. * * @since 0.1.0 */ public function resume( string $token ): IntentSnapshot { $intent = $this->intents->find_by_token_hash( hash( 'sha256', $token ) ); if ( null === $intent || null === $intent->cart_id() ) { throw new DomainException( 'order_intent.not_found', 'Order intent not found for token.' ); } $cart = $this->carts->find_by_id( (int) $intent->cart_id() ); if ( null === $cart ) { throw new DomainException( 'order_intent.cart_missing', 'Cart missing for order intent.' ); } $items = $this->items->find_by_cart_id( (int) $cart->id() ); return new IntentSnapshot( $intent, $cart, $items ); } /** * Load a resumable snapshot by intent id (webhook path). * * @param int $intent_id Intent id. * * @throws DomainException When unknown. * * @since 0.1.0 */ public function resume_by_id( int $intent_id ): IntentSnapshot { $intent = $this->intents->find_by_id( $intent_id ); if ( null === $intent || null === $intent->cart_id() ) { throw new DomainException( 'order_intent.not_found', 'Order intent not found.' ); } $cart = $this->carts->find_by_id( (int) $intent->cart_id() ); if ( null === $cart ) { throw new DomainException( 'order_intent.cart_missing', 'Cart missing for order intent.' ); } $items = $this->items->find_by_cart_id( (int) $cart->id() ); return new IntentSnapshot( $intent, $cart, $items ); } /** * Mark an open intent abandoned (e.g. session expiry mid-flow). * * @param string $token Plaintext token. * * @since 0.1.0 */ public function abandon_if_open( string $token ): void { $intent = $this->intents->find_by_token_hash( hash( 'sha256', $token ) ); if ( null === $intent || OrderIntentStatus::Open !== $intent->status() ) { return; } $now = $this->clock->now(); $intent->mark_abandoned( $now ); $this->intents->save( $intent ); $cart_id = $intent->cart_id(); if ( null !== $cart_id ) { $cart = $this->carts->find_by_id( (int) $cart_id ); if ( null !== $cart ) { $cart->set_status( CartStatus::Abandoned, $now ); $this->carts->save( $cart ); } } } /** * Mutate an open intent and optionally replace cart items; recomputes totals. * * @param string $token Session token. * @param array|null $customer_draft Draft fields. * @param string|null $coupon_code Coupon. * @param string|null $current_step Step. * @param list|null $completed_steps Completed steps. * @param list|null $cart_items Replacement items (null = leave). * * @throws DomainException When not found or not open. * * @since 0.1.0 */ public function mutate( string $token, ?array $customer_draft = null, ?string $coupon_code = null, ?string $current_step = null, ?array $completed_steps = null, ?array $cart_items = null ): IntentSnapshot { $now = $this->clock->now(); $intent = $this->require_open_by_token( $token ); return $this->tx->run( function () use ( $intent, $customer_draft, $coupon_code, $current_step, $completed_steps, $cart_items, $now ): IntentSnapshot { $intent->mutate( $customer_draft, $coupon_code, $current_step, $completed_steps, $now ); $cart_id = (int) $intent->cart_id(); $cart = $this->carts->find_by_id( $cart_id ); if ( null === $cart ) { throw new DomainException( 'order_intent.cart_missing', 'Cart missing for order intent.' ); } if ( null !== $cart_items ) { $this->items->replace_for_cart( $cart_id, $cart_items ); } $saved_items = $this->items->find_by_cart_id( $cart_id ); $intent->replace_totals( $this->compute_totals( $saved_items, $intent ), $now ); $this->intents->save( $intent ); return new IntentSnapshot( $intent, $cart, $saved_items ); } ); } /** * Persist a transaction intent against an open order intent. * * @param string $token Session token. * @param TransactionIntent $tx_intent New transaction intent (order_intent_id ignored). * * @throws DomainException When not found or not open. * * @since 0.1.0 */ public function add_transaction_intent( string $token, TransactionIntent $tx_intent ): TransactionIntent { $intent = $this->require_open_by_token( $token ); $now = $this->clock->now(); $entity = new TransactionIntent( null, $tx_intent->uuid(), (int) $intent->id(), $tx_intent->gateway(), $tx_intent->gateway_intent_id(), $tx_intent->amount(), $tx_intent->payment_type(), $tx_intent->status(), $tx_intent->idempotency_key(), $tx_intent->client_secret_hash(), $tx_intent->failure_code(), $tx_intent->failure_message(), $now, $now, ); $this->tx_intents->save( $entity ); return $entity; } /** * Idempotent conversion via open → converting compare-and-set (docs/19 §4). * * `$create_order` receives the intent snapshot and must return the new order id. * It runs only when this caller wins the claim. A duplicate submit returns the * existing order id with identical semantics. * * @param string $token Session token. * @param callable(IntentSnapshot):int $create_order Order factory. * * @throws DomainException When conversion is blocked. * * @since 0.1.0 */ public function convert( string $token, callable $create_order ): ConversionResult { $intent = $this->intents->find_by_token_hash( hash( 'sha256', $token ) ); if ( null === $intent || null === $intent->id() ) { throw new DomainException( 'order_intent.not_found', 'Order intent not found for token.' ); } $intent_id = (int) $intent->id(); $now = $this->clock->now(); if ( OrderIntentStatus::Converted === $intent->status() ) { $order_id = $intent->converted_order_id(); if ( null === $order_id ) { throw new DomainException( 'order_intent.converted_missing_order', 'Converted intent has no order id.' ); } return ConversionResult::already_converted( $order_id ); } $claimed = $this->intents->claim_converting( $intent_id, $now ); if ( ! $claimed ) { $fresh = $this->intents->find_by_id( $intent_id ); if ( null !== $fresh && OrderIntentStatus::Converted === $fresh->status() && null !== $fresh->converted_order_id() ) { return ConversionResult::already_converted( (int) $fresh->converted_order_id() ); } if ( null !== $fresh && OrderIntentStatus::Converting === $fresh->status() ) { throw new DomainException( 'order_intent.converting', 'Conversion already in progress.' ); } throw new DomainException( 'order_intent.convert_blocked', 'Intent cannot be converted in its current status.' ); } $snapshot = $this->resume( $token ); $order_id = $create_order( $snapshot ); if ( $order_id < 1 ) { throw new DomainException( 'order_intent.order_id', 'Conversion factory must return a positive order id.' ); } $fresh = $this->intents->find_by_id( $intent_id ); if ( null === $fresh ) { throw new DomainException( 'order_intent.not_found', 'Order intent disappeared during conversion.' ); } $fresh->mark_converted( $order_id, $this->clock->now() ); $this->intents->save( $fresh ); $this->emit_recovered( $fresh, $order_id ); $cart = $snapshot->cart(); $cart->set_status( CartStatus::Converted, $this->clock->now() ); $this->carts->save( $cart ); return ConversionResult::created( $order_id ); } /** * Idempotent conversion by intent id (webhook path when token is unavailable). * * @param int $intent_id Intent id. * @param callable(IntentSnapshot):int $create_order Order factory. * * @throws DomainException When conversion is blocked. * * @since 0.1.0 */ public function convert_by_id( int $intent_id, callable $create_order ): ConversionResult { $intent = $this->intents->find_by_id( $intent_id ); if ( null === $intent ) { throw new DomainException( 'order_intent.not_found', 'Order intent not found.' ); } $now = $this->clock->now(); if ( OrderIntentStatus::Converted === $intent->status() ) { $order_id = $intent->converted_order_id(); if ( null === $order_id ) { throw new DomainException( 'order_intent.converted_missing_order', 'Converted intent has no order id.' ); } return ConversionResult::already_converted( $order_id ); } $claimed = $this->intents->claim_converting( $intent_id, $now ); if ( ! $claimed ) { $fresh = $this->intents->find_by_id( $intent_id ); if ( null !== $fresh && OrderIntentStatus::Converted === $fresh->status() && null !== $fresh->converted_order_id() ) { return ConversionResult::already_converted( (int) $fresh->converted_order_id() ); } if ( null !== $fresh && OrderIntentStatus::Converting === $fresh->status() ) { throw new DomainException( 'order_intent.converting', 'Conversion already in progress.' ); } throw new DomainException( 'order_intent.convert_blocked', 'Intent cannot be converted in its current status.' ); } $snapshot = $this->resume_by_id( $intent_id ); $order_id = $create_order( $snapshot ); if ( $order_id < 1 ) { throw new DomainException( 'order_intent.order_id', 'Conversion factory must return a positive order id.' ); } $fresh = $this->intents->find_by_id( $intent_id ); if ( null === $fresh ) { throw new DomainException( 'order_intent.not_found', 'Order intent disappeared during conversion.' ); } $fresh->mark_converted( $order_id, $this->clock->now() ); $this->intents->save( $fresh ); $this->emit_recovered( $fresh, $order_id ); $cart = $snapshot->cart(); $cart->set_status( CartStatus::Converted, $this->clock->now() ); $this->carts->save( $cart ); return ConversionResult::created( $order_id ); } /** * Hash a plaintext token the same way create() does. * * @param string $token Plaintext token. * * @since 0.1.0 */ public static function hash_token( string $token ): string { return hash( 'sha256', $token ); } /** * Find an intent by database id. * * @param int $intent_id Intent id. * * @since 0.1.0 */ public function find_by_id( int $intent_id ): ?OrderIntent { return $this->intents->find_by_id( $intent_id ); } /** * Default currency from settings. * * @since 0.1.0 */ public function default_currency(): string { $currency = $this->settings->get( 'general.currency' ); return is_string( $currency ) && '' !== $currency ? strtoupper( $currency ) : 'GBP'; } /** * Require an open intent by token. * * @param string $token Token. * * @throws DomainException When missing or not open. */ private function require_open_by_token( string $token ): OrderIntent { $intent = $this->intents->find_by_token_hash( hash( 'sha256', $token ) ); if ( null === $intent ) { throw new DomainException( 'order_intent.not_found', 'Order intent not found for token.' ); } if ( OrderIntentStatus::Open !== $intent->status() ) { throw new DomainException( 'order_intent.not_open', 'Only an open intent may be mutated.' ); } return $intent; } /** * Sum cart item prices into a totals payload (server-side; never trust the client). * * @param list $items Items. * @param OrderIntent $intent Intent (coupon + settings). * @return array */ private function compute_totals( array $items, OrderIntent $intent ): array { if ( $this->live_totals instanceof LiveTotalsCalculator ) { return $this->live_totals->compute( $items, $intent->coupon_code(), (bool) $this->settings->get( 'payments.pay_on_site_only' ) ); } $currency = $this->default_currency(); $subtotal = 0; foreach ( $items as $item ) { $currency = $item->price()->currency(); $subtotal += $item->price()->amount_minor(); } return [ 'subtotal_minor' => $subtotal, 'discount_minor' => 0, 'tax_minor' => 0, 'total_minor' => $subtotal, 'due_now_minor' => $subtotal, 'due_later_minor' => 0, 'currency' => $currency, ]; } /** * Notify automations that an abandoned (or converting) intent converted. * * @param OrderIntent $intent Intent. * @param int $order_id Order id. */ private function emit_recovered( OrderIntent $intent, int $order_id ): void { if ( null === $this->bus || null === $intent->id() ) { return; } $this->bus->dispatch( new IntentRecovered( (int) $intent->id(), null, null, $this->clock->now() ) ); unset( $order_id ); } }