builder = new Receipt_Data_Builder(); $this->fiscal_service = new Fiscal_Receipt_Service(); add_action( 'woocommerce_payment_complete', array( $this, 'handle_payment_complete' ) ); } /** * Get singleton instance. * * @return self */ public static function instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Handle payment complete event and persist snapshot if needed. * * @param int $order_id Order ID. */ public function handle_payment_complete( int $order_id ): void { $order = wc_get_order( $order_id ); if ( ! $order ) { return; } if ( $this->has_snapshot( $order_id ) ) { return; } $snapshot = $this->builder->build( $order, 'fiscal' ); try { $this->persist_snapshot( $order_id, $snapshot ); } catch ( RuntimeException $e ) { Logger::log( 'Unable to persist receipt snapshot: ' . $e->getMessage() ); } } /** * Check if order has a stored snapshot. * * @param int $order_id Order ID. * * @return bool */ public function has_snapshot( int $order_id ): bool { $order = wc_get_order( $order_id ); if ( ! $order ) { return false; } $payload = $order->get_meta( self::META_KEY_PAYLOAD, true ); return \is_string( $payload ) && '' !== $payload; } /** * Get stored snapshot. * * @param int $order_id Order ID. * * @return null|array */ public function get_snapshot( int $order_id ): ?array { $order = wc_get_order( $order_id ); if ( ! $order ) { return null; } $payload = $order->get_meta( self::META_KEY_PAYLOAD, true ); if ( ! \is_string( $payload ) || '' === $payload ) { return null; } $decoded = json_decode( $payload, true ); if ( ! \is_array( $decoded ) ) { return null; } return $decoded; } /** * Persist immutable snapshot fields. * * @param int $order_id Order ID. * @param array $snapshot Snapshot payload. * * @throws RuntimeException When the order snapshot lock cannot be acquired. * @throws RuntimeException When JSON encoding fails. * @throws RuntimeException When the order does not exist. */ public function persist_snapshot( int $order_id, array $snapshot ): void { global $wpdb; $lock_name = 'wcpos_receipt_snapshot_lock_' . $order_id; $acquired = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, %d )', $lock_name, 5 ) ); if ( 1 !== $acquired ) { throw new RuntimeException( 'Unable to acquire receipt snapshot lock' ); } try { if ( $this->has_snapshot( $order_id ) ) { return; } $sequence = $this->next_sequence(); $created = current_time( 'mysql', true ); $snapshot['fiscal']['sequence'] = $sequence; $snapshot['fiscal']['receipt_number'] = (string) $sequence; $snapshot['fiscal']['immutable_id'] = $order_id . ':' . $sequence; $snapshot = $this->fiscal_service->enrich_snapshot( $snapshot, $order_id ); $json = wp_json_encode( $snapshot ); if ( ! \is_string( $json ) ) { Logger::log( 'Failed to encode receipt snapshot to JSON: ' . json_last_error_msg() ); throw new RuntimeException( 'Failed to encode receipt snapshot to JSON' ); } $checksum = hash( 'sha256', $json ); $order = wc_get_order( $order_id ); if ( ! $order ) { throw new RuntimeException( 'Order not found when persisting snapshot' ); } $order->update_meta_data( self::META_KEY_PAYLOAD, $json ); $order->update_meta_data( self::META_KEY_CHECKSUM, $checksum ); $order->update_meta_data( self::META_KEY_SEQUENCE, (string) $sequence ); $order->update_meta_data( self::META_KEY_CREATED_AT, $created ); $order->save(); /* translators: %d: receipt sequence number. */ $order->add_order_note( \sprintf( __( 'POS fiscal receipt snapshot created (receipt #%d).', 'woocommerce-pos' ), $sequence ) ); $this->fiscal_service->set_submission_status( $order_id, 'pending' ); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', $lock_name ) ); } } /** * Get the default mode from settings. * * @return string */ public function get_default_mode(): string { $checkout_settings = woocommerce_pos_get_settings( 'checkout' ); $mode = \is_array( $checkout_settings ) ? ( $checkout_settings['receipt_default_mode'] ?? 'fiscal' ) : 'fiscal'; return in_array( $mode, array( 'fiscal', 'live' ), true ) ? $mode : 'fiscal'; } /** * Resolve effective receipt mode. * * @param null|string $mode Requested mode. * * @return string */ public function resolve_mode( ?string $mode = null ): string { if ( in_array( $mode, array( 'fiscal', 'live' ), true ) ) { return $mode; } return $this->get_default_mode(); } /** * Get next sequence number. * * @return int * * @throws RuntimeException When the sequence lock cannot be acquired. */ private function next_sequence(): int { global $wpdb; $lock_name = 'wcpos_receipt_sequence_lock'; $acquired = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, %d )', $lock_name, 5 ) ); if ( 1 !== $acquired ) { throw new RuntimeException( 'Unable to acquire receipt sequence lock' ); } try { $sequence = (int) get_option( self::OPTION_SEQUENCE, 0 ); $sequence++; update_option( self::OPTION_SEQUENCE, $sequence, false ); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', $lock_name ) ); } return $sequence; } }