array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'name' => 'varchar(120)', 'service_ids_json' => 'longtext', 'units' => 'int unsigned', 'valid_days' => 'int unsigned', 'currency' => 'char(3)', 'policy_json' => 'longtext', 'state' => 'varchar(20)', 'revision' => 'bigint unsigned', 'created_at' => 'datetime' ), 'credit_grants' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'customer_id' => 'bigint unsigned', 'package_id' => 'bigint unsigned', 'units' => 'int unsigned', 'eligibility_json' => 'longtext', 'expires_at' => 'datetime', 'policy_json' => 'longtext', 'acquisition_cents' => 'bigint unsigned', 'currency' => 'char(3)', 'state' => 'varchar(20)', 'created_at' => 'datetime' ), 'credit_applications' => array( 'appointment_id' => 'bigint unsigned', 'grant_id' => 'bigint unsigned', 'units' => 'int unsigned', 'covered_cents' => 'bigint unsigned', 'currency' => 'char(3)', 'state' => 'varchar(20)', 'policy_json' => 'longtext', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'created_at' => 'datetime' ), 'credit_movements' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'grant_id' => 'bigint unsigned', 'appointment_id' => 'bigint unsigned', 'kind' => 'varchar(20)', 'units_delta' => 'int', 'created_at' => 'datetime', 'actor_id' => 'bigint unsigned' ), 'credit_authorities' => array( 'id' => 'bigint unsigned', 'command_uuid' => 'char(36)', 'payload_hash' => 'char(64)', 'grant_id' => 'bigint unsigned', 'customer_id' => 'bigint unsigned', 'token_hash' => 'char(64)', 'expires_at' => 'datetime', 'revoked_at' => 'datetime', 'created_at' => 'datetime' ) ); $keys = array(); foreach ( $definitions as$table => $columns ) { $keys[ $table ] = array( array( $table === 'credit_applications' ? 'appointment_id' : 'id' ), array( 'command_uuid' ) ); if ( $table === 'credit_authorities' ) { $keys[ $table ][] = array( 'token_hash' ); } } if ( ! OptionalStoreSchema::present( $read, 'abientot_credits_schema_version', '1', $definitions, $keys ) ) { return false; } global $wpdb; foreach ( $definitions as$table => $columns ) { $engine = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( strtoupper( (string) ( $engine[0]['ENGINE'] ?? '' ) ) !== 'INNODB' ) { throw new \RuntimeException( 'credit_retained_engine' ); } foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' )as$column ) { $nullable = in_array( $table . '.' . $column['Field'], array( 'credit_grants.customer_id', 'credit_movements.appointment_id', 'credit_movements.actor_id', 'credit_authorities.customer_id', 'credit_authorities.revoked_at' ), true ); if ( isset( $columns[ $column['Field'] ] ) && ( $column['Null'] === 'YES' ) !== $nullable ) { throw new \RuntimeException( 'credit_retained_nullability' ); } } } return true; } public function application( int $id ): ?array { if ( ! $this->present() ) { return null; } global $wpdb; $read = new ResourceCapacityRepository(); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE appointment_id=%d', $read->table( 'credit_applications' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return $rows[0] ?? null; } public function coverage( int $id, string $currency, int $gross ): int { $application = $this->application( $id ); if ( ! $application ) { return 0; } if ( ! in_array( $application['state'], array( 'reserved', 'consumed', 'restored' ), true ) || $application['currency'] !== $currency || (int) $application['covered_cents'] < 0 || (int) $application['covered_cents'] > $gross ) { throw new \DomainException( 'credit_retained_facts' ); } return $application['state'] === 'restored' ? 0 : (int) $application['covered_cents']; } private function write( $result ): void { global $wpdb; if ( $result === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'credit_retained_write' ); } } public function guard( array $after, ?array $before ): void { $app = $this->application( (int) $after['id'] ); if ( ! $app ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'credit_retained_owner' ); } global $wpdb; $read = new ResourceCapacityRepository(); $grant = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', $read->table( 'credit_grants' ), (int) $app['grant_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $grant ) { throw new \RuntimeException( 'credit_retained_grant' ); } $grant = $grant[0]; $this->coverage( (int) $after['id'], (string) $after['currency'], (int) $after['price_cents'] ); if ( $before ) { foreach ( array( 'price_cents', 'currency', 'customer_id', 'service_id' )as$key ) { if ( (string) $before[ $key ] !== (string) $after[ $key ] ) { throw new \DomainException( 'credit_immutable_booking' ); } } } if ( $before && $after['start_utc'] !== $before['start_utc'] && $app['state'] !== 'restored' && ( $grant['state'] !== 'active' || $after['start_utc'] >= $grant['expires_at'] ) ) { throw new \DomainException( 'credit_expired_move' ); } $status = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT slug,blocks_availability FROM %i WHERE id=%d', $read->table( 'booking_statuses' ), (int) $after['status_id'] ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( ! $status ) { throw new \RuntimeException( 'credit_retained_status' ); } $slug = $status[0]['slug']; if ( $app['state'] === 'restored' ) { if ( $before && \Abientot\Support\BookingConstraints::blocking( $after ) && ! \Abientot\Support\BookingConstraints::blocking( $before ) ) { throw new \DomainException( 'credit_reactivation_requires_booking' ); } return; } if ( $app['state'] === 'consumed' || ! in_array( $slug, array( 'cancelled', 'completed', 'no_show' ), true ) ) { return; } $policy = json_decode( $app['policy_json'], true, 32, JSON_THROW_ON_ERROR ); if ( ! is_array( $policy ) || ( $policy['version'] ?? null ) !== 1 || ( $policy['coverage'] ?? '' ) !== 'base_only' || ! in_array( $policy['late_cancel'] ?? '', array( 'consume', 'restore' ), true ) || ! in_array( $policy['no_show'] ?? '', array( 'consume', 'restore' ), true ) || ! is_int( $policy['cancel_cutoff_hours'] ?? null ) || $policy['cancel_cutoff_hours'] < 0 || $policy['cancel_cutoff_hours'] > 168 ) { throw new \RuntimeException( 'credit_retained_policy' ); } $start = strtotime( $after['start_utc'] . ' UTC' ); if ( $start === false ) { throw new \RuntimeException( 'credit_retained_time' ); } $restore = ( $slug === 'cancelled' && ( $start - time() >= $policy['cancel_cutoff_hours'] * HOUR_IN_SECONDS || $policy['late_cancel'] === 'restore' ) ) || ( $slug === 'no_show' && $policy['no_show'] === 'restore' ); $kind = $restore ? 'restore' : 'consume'; $hex = hash( 'sha256', 'credit-' . $kind . ':' . $app['appointment_id'] . ':' . $app['grant_id'] ); $uuid = substr( $hex, 0, 8 ) . '-' . substr( $hex, 8, 4 ) . '-5' . substr( $hex, 13, 3 ) . '-a' . substr( $hex, 17, 3 ) . '-' . substr( $hex, 20, 12 ); $this->write( $wpdb->insert( $read->table( 'credit_movements' ), array( 'command_uuid' => $uuid, 'grant_id' => (int) $app['grant_id'], 'appointment_id' => (int) $app['appointment_id'], 'kind' => $kind, 'units_delta' => $restore ? (int) $app['units'] : 0, 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'actor_id' => get_current_user_id() ?: null ) ) ); $count = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET state=%s WHERE appointment_id=%d AND state=%s', $read->table( 'credit_applications' ), $restore ? 'restored' : 'consumed', (int) $app['appointment_id'], 'reserved' ) ); $this->write( $count ); if ( $count !== 1 ) { throw new \RuntimeException( 'credit_retained_stale' ); } } public function erase_customer( int $id ): void { if ( ! $this->present() ) { return; } if ( ! BookingLock::is_held() ) { throw new \RuntimeException( 'credit_retained_owner' ); } global $wpdb; $p = $wpdb->prefix . 'abientot_'; $now = gmdate( 'Y-m-d H:i:s' ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i a LEFT JOIN %i g ON g.id=a.grant_id SET a.customer_id=NULL,a.revoked_at=%s,a.payload_hash='' WHERE a.customer_id=%d OR g.customer_id=%d", $p . 'credit_authorities', $p . 'credit_grants', $now, $id, $id ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i m INNER JOIN %i g ON g.id=m.grant_id SET m.actor_id=NULL WHERE g.customer_id=%d", $p . 'credit_movements', $p . 'credit_grants', $id ) ) ); $this->write( $wpdb->query( $wpdb->prepare( "UPDATE %i SET state='erased',customer_id=NULL,payload_hash='' WHERE customer_id=%d", $p . 'credit_grants', $id ) ) ); } }