cyberia/research/events/ethereum/contracts/CyberValleyEventManager.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./CyberValleyEventTicket.sol";
import "./DateOverlapChecker.sol";
import "./CyberValley.sol";
import "./IDynamicRevenueSplitter.sol";
import "./IReferralRewards.sol";

// TODO: Pad layout after general work finish
contract CyberValleyEventManager is AccessControl, DateOverlapChecker {
    using CyberValley for CyberValley.Multihash;

    bytes32 public constant MASTER_ROLE = keccak256("MASTER_ROLE");
    bytes32 public constant LOCAL_PROVIDER_ROLE = keccak256("LOCAL_PROVIDER_ROLE");
    bytes32 public constant VERIFIED_SHAMAN_ROLE = keccak256("VERIFIED_SHAMAN_ROLE");
    bytes32 public constant BACKEND_ROLE = keccak256("BACKEND_ROLE");

    // LOCAL_PROVIDER_ROLE is the primary "event manager" role; MASTER_ROLE should be
    // able to act across all providers for operational recovery (e.g. close/cancel).
    modifier onlyLocalProviderOrMaster() {
        if (!hasRole(MASTER_ROLE, msg.sender)) {
            _checkRole(LOCAL_PROVIDER_ROLE);
        }
        _;
    }

    enum EventPlaceStatus {
        Submitted,
        Approved,
        Declined
    }

    struct EventPlace {
        address requester;
        address provider;
        uint16 maxTickets;
        uint16 minTickets;
        uint256 minPrice;
        uint8 daysBeforeCancel;
        uint8 minDays;
        bool available;
        EventPlaceStatus status;
        CyberValley.Multihash meta;
        uint256 eventDepositSize;
    }

    enum EventStatus {
        Submitted,
        Approved,
        Declined,
        Cancelled,
        Closed
    }

    // TODO: Add IPFS multihash
    struct TicketCategory {
        string name;
        uint256 eventId;
        uint16 discountPercentage;
        uint16 quota;
        bool hasQuota;
        uint16 sold;
    }

    // Input struct for creating categories during event submission
    struct CategoryInput {
        string name;
        uint16 discountPercentage;
        uint16 quota;
        bool hasQuota;
    }

    struct Event {
        address creator;
        uint256 eventPlaceId;
        uint256 ticketPrice;
        uint256 startDate;
        uint16 daysAmount;
        EventStatus status;
        address[] customers;
        CyberValley.Multihash meta;
        uint256 networth;
        uint256 totalCategoryQuota; // Gas-efficient tracking of category quotas
    }

    event NewEventPlaceRequest(
        uint256 id,
        address requester,
        uint16 maxTickets,
        uint16 minTickets,
        uint256 minPrice,
        uint8 daysBeforeCancel,
        uint8 minDays,
        bool available,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size
    );
    event EventPlaceUpdated(
        address provider,
        uint256 eventPlaceId,
        uint16 maxTickets,
        uint16 minTickets,
        uint256 minPrice,
        uint8 daysBeforeCancel,
        uint8 minDays,
        bool available,
        EventPlaceStatus status,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        uint256 eventDepositSize
    );
    event NewEventRequest(
        uint256 id,
        address creator,
        uint256 eventPlaceId,
        uint256 ticketPrice,
        uint256 startDate,
        uint16 daysAmount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size
    );
    event EventStatusChanged(uint256 eventId, EventStatus status);
    event EventUpdated(
        uint256 id,
        uint256 eventPlaceId,
        uint256 ticketPrice,
        uint256 startDate,
        uint16 daysAmount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size
    );
    event EventTicketVerified(uint256 tokenId);

    event TicketCategoryCreated(
        uint256 categoryId,
        uint256 indexed eventId,
        string name,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota
    );
    event TicketCategoryUpdated(
        uint256 categoryId,
        uint256 indexed eventId,
        string name,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota
    );
    event ReferralRewardsUpdated(address referralRewards);

    IERC20 public usdtTokenContract;
    CyberValleyEventTicket public eventTicketContract;

    address public master;
    address public revenueSplitter;
    IReferralRewards public referralRewards;

    EventPlace[] public eventPlaces;
    Event[] public events;
    TicketCategory[] public categories;
    mapping(uint256 => uint256[]) public categoryCounters;
    // NOTE: `ticketPrices` was removed because it was written but never read anywhere.

    modifier onlyExistingEvent(uint256 eventId) {
        require(eventId < events.length, "Event with given id does not exist");
        _;
    }

    /**
     * @notice Check if a place has any active events (submitted or approved status)
     * @param eventPlaceId The ID of the event place
     * @return true if there are active events at this place
     */
    function _hasActiveEvents(uint256 eventPlaceId) internal view returns (bool) {
        for (uint256 i = 0; i < events.length; i++) {
            if (events[i].eventPlaceId == eventPlaceId) {
                if (events[i].status == EventStatus.Submitted || events[i].status == EventStatus.Approved) {
                    return true;
                }
            }
        }
        return false;
    }

    constructor(
        address _usdtTokenContract,
        address _eventTicketContract,
        address _master,
        uint256 _initialOffest
    ) DateOverlapChecker(_initialOffest) {
        usdtTokenContract = IERC20(_usdtTokenContract);
        eventTicketContract = CyberValleyEventTicket(_eventTicketContract);
        master = _master;

        _grantRole(DEFAULT_ADMIN_ROLE, _master);
        _grantRole(MASTER_ROLE, _master);
        _grantRole(LOCAL_PROVIDER_ROLE, _master);
        _setRoleAdmin(VERIFIED_SHAMAN_ROLE, BACKEND_ROLE);
    }

    function setReferralRewards(address _referralRewards) external onlyRole(MASTER_ROLE) {
        referralRewards = IReferralRewards(_referralRewards);
        emit ReferralRewardsUpdated(_referralRewards);
    }

    /**
     * @notice Grants local provider role and sets their bps share in RevenueSplitter
     * @param eoa The address to grant LOCAL_PROVIDER_ROLE to
     * @param bps The basis points (0-8500) this provider receives from each distribution.
     *           Applied after fixed bps (CyberiaDAO 10% + CVE PT PMA 5%) and before 
     *           profile recipients. Provider cannot add themselves to distribution profiles.
     */
    function grantLocalProvider(address eoa, uint256 bps) external onlyRole(MASTER_ROLE) {
        require(eoa != address(0), "Local provider cannot be zero address");
        require(revenueSplitter != address(0), "Revenue splitter not set");
        _grantRole(LOCAL_PROVIDER_ROLE, eoa);
        // Bps validation happens in RevenueSplitter. Provider gets bps automatically
        // and cannot add themselves to distribution profiles.
        IDynamicRevenueSplitter(revenueSplitter).grantProfileManager(eoa, bps);
    }

    function revokeLocalProvider(address eoa) external onlyRole(MASTER_ROLE) {
        // Transfer all EventPlaces owned by the LocalProvider to Master
        for (uint256 i = 0; i < eventPlaces.length; i++) {
            if (eventPlaces[i].provider == eoa) {
                eventPlaces[i].provider = master;

                // Emit EventPlaceUpdated event for the transfer
                emit EventPlaceUpdated(
                    master,
                    i,
                    eventPlaces[i].maxTickets,
                    eventPlaces[i].minTickets,
                    eventPlaces[i].minPrice,
                    eventPlaces[i].daysBeforeCancel,
                    eventPlaces[i].minDays,
                    eventPlaces[i].available,
                    eventPlaces[i].status,
                    eventPlaces[i].meta.digest,
                    eventPlaces[i].meta.hashFunction,
                    eventPlaces[i].meta.size,
                    eventPlaces[i].eventDepositSize
                );
            }
        }

        // Transfer all profile ownership from revoked provider to master
        IDynamicRevenueSplitter(revenueSplitter).transferAllProfiles(eoa, master);

        _revokeRole(LOCAL_PROVIDER_ROLE, eoa);
    }

    function revokeVerifiedShaman(address eoa) external onlyRole(LOCAL_PROVIDER_ROLE) {
        _revokeRole(VERIFIED_SHAMAN_ROLE, eoa);
    }

    function setRevenueSplitter(address _splitter) external onlyRole(MASTER_ROLE) {
        require(_splitter != address(0), "Splitter address cannot be zero");
        revenueSplitter = _splitter;
    }

    function submitEventPlaceRequest(
        uint16 _maxTickets,
        uint16 _minTickets,
        uint256 _minPrice,
        uint8 _daysBeforeCancel,
        uint8 _minDays,
        bool _available,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        uint256 _eventDepositSize
    ) external {
        bool isLocalProvider = hasRole(LOCAL_PROVIDER_ROLE, msg.sender);
	require(isLocalProvider || hasRole(VERIFIED_SHAMAN_ROLE, msg.sender), "access denied");

        // If local provider is creating, deposit must be > 0
        // If shaman is requesting, deposit can be 0 (will be set during approval)
        if (isLocalProvider) {
            require(_eventDepositSize > 0, "Deposit must be greater than zero");
        }

        eventPlaces.push(EventPlace({
            requester: msg.sender,
            provider: isLocalProvider ? msg.sender : address(0),
            maxTickets: _maxTickets,
            minTickets: _minTickets,
            minPrice: _minPrice,
            daysBeforeCancel: _daysBeforeCancel,
            minDays: _minDays,
            available: _available,
            status: isLocalProvider ? EventPlaceStatus.Approved : EventPlaceStatus.Submitted,
            meta: CyberValley.Multihash({
                digest: digest,
                hashFunction: hashFunction,
                size: size
            }),
            eventDepositSize: _eventDepositSize
        }));

        uint256 eventPlaceId = eventPlaces.length - 1;
        _validateEventPlace(eventPlaces[eventPlaceId]);

        if (isLocalProvider) {
            emit EventPlaceUpdated(
                msg.sender,
                eventPlaceId,
                _maxTickets,
                _minTickets,
                _minPrice,
                _daysBeforeCancel,
                _minDays,
                _available,
                EventPlaceStatus.Approved,
                digest,
                hashFunction,
                size,
                _eventDepositSize
            );
        } else {
            emit NewEventPlaceRequest(
                eventPlaceId,
                msg.sender,
                _maxTickets,
                _minTickets,
                _minPrice,
                _daysBeforeCancel,
                _minDays,
                _available,
                digest,
                hashFunction,
                size
            );
        }
    }

    function approveEventPlace(
        uint256 eventPlaceId,
        uint256 _eventDepositSize
    ) external onlyRole(LOCAL_PROVIDER_ROLE) {
        require(eventPlaceId < eventPlaces.length, "EventPlace does not exist");
        require(_eventDepositSize > 0, "Deposit must be greater than zero");
        EventPlace storage place = eventPlaces[eventPlaceId];
        require(
            place.status == EventPlaceStatus.Submitted,
            "EventPlace status differs from submitted"
        );
        place.status = EventPlaceStatus.Approved;
        place.provider = msg.sender;
        place.eventDepositSize = _eventDepositSize;
        emit EventPlaceUpdated(
            msg.sender,
            eventPlaceId,
            place.maxTickets,
            place.minTickets,
            place.minPrice,
            place.daysBeforeCancel,
            place.minDays,
            place.available,
            place.status,
            place.meta.digest,
            place.meta.hashFunction,
            place.meta.size,
            place.eventDepositSize
        );
    }

    function declineEventPlace(
        uint256 eventPlaceId
    ) external onlyRole(LOCAL_PROVIDER_ROLE) {
        require(eventPlaceId < eventPlaces.length, "EventPlace does not exist");
        EventPlace storage place = eventPlaces[eventPlaceId];
        require(
            place.status == EventPlaceStatus.Submitted,
            "EventPlace status differs from submitted"
        );
        place.status = EventPlaceStatus.Declined;
        // No event emitted for declined places
    }

    function updateEventPlace(
        uint256 eventPlaceId,
        uint16 _maxTickets,
        uint16 _minTickets,
        uint256 _minPrice,
        uint8 _daysBeforeCancel,
        uint8 _minDays,
        bool _available,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        uint256 _eventDepositSize
    ) external onlyRole(LOCAL_PROVIDER_ROLE) {
        require(eventPlaceId < eventPlaces.length, "eventPlaceId should exist");
        EventPlace storage place = eventPlaces[eventPlaceId];
        require(
            place.provider == msg.sender,
            "Given event place belongs to another provider"
        );
        require(
            place.status == EventPlaceStatus.Approved,
            "EventPlace must be approved to be updated"
        );

        // Only allow deposit change if there are no active events
        if (_eventDepositSize != place.eventDepositSize) {
            require(
                !_hasActiveEvents(eventPlaceId),
                "Cannot change deposit while there are active events"
            );
            require(_eventDepositSize > 0, "Deposit must be greater than zero");
            place.eventDepositSize = _eventDepositSize;
        }

        place.maxTickets = _maxTickets;
        place.minTickets = _minTickets;
        place.minPrice = _minPrice;
        place.daysBeforeCancel = _daysBeforeCancel;
        place.minDays = _minDays;
        place.available = _available;
        place.meta = CyberValley.Multihash({
            digest: digest,
            hashFunction: hashFunction,
            size: size
        });

        _validateEventPlace(place);
        emit EventPlaceUpdated(
            msg.sender,
            eventPlaceId,
            _maxTickets,
            _minTickets,
            _minPrice,
            _daysBeforeCancel,
            _minDays,
            _available,
            place.status,
            digest,
            hashFunction,
            size,
            place.eventDepositSize
        );
    }

    function _validateEventPlace(EventPlace memory eventPlace) internal pure {
        require(
            eventPlace.maxTickets >= eventPlace.minTickets,
            "Max tickets must be greater or equal min tickets"
        );
        require(
            eventPlace.maxTickets > 0 &&
                eventPlace.minTickets > 0 &&
                eventPlace.minPrice > 0 &&
                eventPlace.daysBeforeCancel > 0 &&
                eventPlace.minDays > 0,
            "Values must be greater than zero"
        );
    }

    function submitEventRequest(
        uint256 eventPlaceId,
        uint256 ticketPrice,
        uint256 startDate,
        uint16 daysAmount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        CategoryInput[] calldata categoriesInput
    ) external {
        require(eventPlaceId < eventPlaces.length, "EventPlace does not exist");
        EventPlace storage place = eventPlaces[eventPlaceId];
        require(
            place.eventDepositSize > 0,
            "EventPlace deposit must be set"
        );
        require(
            usdtTokenContract.balanceOf(msg.sender) >= place.eventDepositSize,
            "Not enough tokens"
        );
        require(
            usdtTokenContract.allowance(msg.sender, address(this)) >=
                place.eventDepositSize,
            "Required amount was not allowed"
        );
        require(
            usdtTokenContract.transferFrom(
                msg.sender,
                address(this),
                place.eventDepositSize
            ),
            "Event request payment failed"
        );
        events.push(
            Event({
                creator: msg.sender,
                eventPlaceId: eventPlaceId,
                ticketPrice: ticketPrice,
                startDate: floorTimestampToDate(startDate),
                daysAmount: daysAmount,
                status: EventStatus.Submitted,
                customers: new address[](0),
                meta: CyberValley.Multihash({
                    digest: digest,
                    hashFunction: hashFunction,
                    size: size
                }),
                networth: 0,
                totalCategoryQuota: 0
            })
        );
        uint256 eventId = events.length - 1;
        validateEvent(events[eventId]);

        // Create categories atomically with the event
        require(categoriesInput.length > 0, "At least one category is required");
        for (uint256 i = 0; i < categoriesInput.length; i++) {
            CategoryInput memory cat = categoriesInput[i];
            _createCategoryForEvent(
                eventId,
                cat.name,
                cat.discountPercentage,
                cat.quota,
                cat.hasQuota
            );
        }

        // Validate that categories are within boundaries
        Event storage newEvent = events[eventId];
        EventPlace storage eventPlace = eventPlaces[newEvent.eventPlaceId];
        bool hasUnlimited = _eventHasUnlimitedCategory(eventId);
        if (!hasUnlimited) {
            require(
                newEvent.totalCategoryQuota >= eventPlace.minTickets,
                "Total tickets must be at least minTickets"
            );
            require(
                newEvent.totalCategoryQuota <= eventPlace.maxTickets,
                "Total tickets cannot exceed maxTickets"
            );
        }

        emit NewEventRequest(
            eventId,
            msg.sender,
            eventPlaceId,
            ticketPrice,
            startDate,
            daysAmount,
            digest,
            hashFunction,
            size
        );
    }

    function approveEvent(
        uint256 eventId,
        uint256 distributionProfileId
    ) external onlyLocalProviderOrMaster onlyExistingEvent(eventId) {
        Event storage evt = events[eventId];
        ensureEventBelongsToProviderOrMaster(evt.eventPlaceId);
        require(
            evt.status == EventStatus.Submitted,
            "Event status differs from submitted"
        );
        require(
            categoryCounters[eventId].length > 0,
            "Event must have at least one category"
        );

        // Validate profile ownership - caller must own the profile
        require(
            IDynamicRevenueSplitter(revenueSplitter).isProfileOwner(
                distributionProfileId,
                msg.sender
            ),
            "Caller must own the distribution profile"
        );

        // Set event profile in revenue splitter
        IDynamicRevenueSplitter(revenueSplitter).setEventProfile(
            eventId,
            distributionProfileId
        );

        allocateDateRange(
            evt.eventPlaceId,
            evt.startDate,
            calcDaysAfter(evt.startDate, evt.daysAmount)
        );
        evt.status = EventStatus.Approved;
        emit EventStatusChanged(eventId, evt.status);
    }

    function declineEvent(
        uint256 eventId
    ) external onlyLocalProviderOrMaster onlyExistingEvent(eventId) {
        Event storage evt = events[eventId];
        ensureEventBelongsToProviderOrMaster(evt.eventPlaceId);
        require(
            evt.status == EventStatus.Submitted,
            "Event status differs from submitted"
        );
        EventPlace storage place = eventPlaces[evt.eventPlaceId];
        require(
            usdtTokenContract.transfer(evt.creator, place.eventDepositSize),
            "Failed to refund event request"
        );
        evt.status = EventStatus.Declined;
        freeDateRange(
            evt.eventPlaceId,
            evt.startDate,
            calcDaysAfter(evt.startDate, evt.daysAmount)
        );
        emit EventStatusChanged(eventId, evt.status);
    }

    function updateEvent(
        uint256 eventId,
        uint256 eventPlaceId,
        uint256 ticketPrice,
        uint256 startDate,
        uint16 daysAmount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size
    ) external onlyRole(LOCAL_PROVIDER_ROLE) onlyExistingEvent(eventId) {
        Event storage evt = events[eventId];
        ensureEventBelongsToProvider(evt.eventPlaceId);
        
        // Capture old values BEFORE any mutations
        uint256 oldEventPlaceId = evt.eventPlaceId;
        uint256 oldStartDate = evt.startDate;
        uint256 oldDaysAmount = evt.daysAmount;
        uint256 normalizedStartDate = floorTimestampToDate(startDate);

        bool realloc = oldEventPlaceId != eventPlaceId ||
            oldStartDate != normalizedStartDate ||
            oldDaysAmount != daysAmount;

        if (realloc) {
            // Remove previous allocation first, otherwise validation may detect
            // overlap against the same event's currently allocated range.
            freeDateRange(
                oldEventPlaceId,
                oldStartDate,
                calcDaysAfter(oldStartDate, oldDaysAmount)
            );
        }
        
        evt.eventPlaceId = eventPlaceId;
        evt.ticketPrice = ticketPrice;
        evt.startDate = normalizedStartDate;
        evt.daysAmount = daysAmount;
        evt.meta = CyberValley.Multihash({
            digest: digest,
            hashFunction: hashFunction,
            size: size
        });
        validateEvent(evt);
        if (realloc) {
            // Allocate the NEW date range to the NEW place
            allocateDateRange(
                eventPlaceId,
                evt.startDate,
                calcDaysAfter(evt.startDate, evt.daysAmount)
            );
        }
        emit EventUpdated(
            eventId,
            eventPlaceId,
            ticketPrice,
            startDate,
            daysAmount,
            digest,
            hashFunction,
            size
        );
    }

    function validateEvent(Event storage evt) internal view {
        EventPlace storage place = eventPlaces[evt.eventPlaceId];
        require(evt.status <= EventStatus.Approved, "Event is not available");
        require(
            place.status == EventPlaceStatus.Approved,
            "EventPlace must be approved"
        );
        require(
            place.available,
            "EventPlace is not available"
        );
        require(
            place.minPrice <= evt.ticketPrice,
            "Ticket price is less than allowed"
        );
        require(
            place.minDays <= evt.daysAmount,
            "Days amount is less than allowed"
        );
        require(
            floorTimestampToDate(block.timestamp) +
                SECONDS_IN_DAY *
                (place.daysBeforeCancel) <=
                evt.startDate,
            "Not enough time to avoid cancelling"
        );
        // Saves from requests that will allocate a lot of buckets
        // in the `DateOverlapChecker`
        // Written when BUCKET_SIZE == 256
        require(
            evt.startDate - floorTimestampToDate(block.timestamp) <=
                SECONDS_IN_DAY * BUCKET_SIZE,
            "Requested event is too far in the future"
        );
        require(
            checkNoOverlap(
                evt.eventPlaceId,
                evt.startDate,
                calcDaysAfter(evt.startDate, evt.daysAmount)
            ),
            "Requested event overlaps with existing"
        );
    }

    function mintTicket(
        uint256 eventId,
        uint256 categoryId,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        address referrer
    ) external onlyExistingEvent(eventId) {
        _mintTicketInternal(eventId, categoryId, 1, digest, hashFunction, size, referrer);
    }

    function mintTickets(
        uint256 eventId,
        uint256 categoryId,
        uint256 amount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        address referrer
    ) external onlyExistingEvent(eventId) {
        _mintTicketInternal(eventId, categoryId, amount, digest, hashFunction, size, referrer);
    }

    function _mintTicketInternal(
        uint256 eventId,
        uint256 categoryId,
        uint256 amount,
        bytes32 digest,
        uint8 hashFunction,
        uint8 size,
        address referrer
    ) internal onlyExistingEvent(eventId) {
        require(amount > 0, "Amount must be greater than 0");

        Event storage evt = events[eventId];
        require(evt.status == EventStatus.Approved, "Event is not approved");

        // Category is required
        require(categoryId < categories.length, "Category does not exist");
        TicketCategory storage category = categories[categoryId];
        require(category.eventId == eventId, "Category does not belong to this event");

        if (category.hasQuota) {
            require(category.sold + uint16(amount) <= category.quota, "Category quota exceeded");
            category.sold += uint16(amount);
        }
        uint256 unitPrice = applyDiscount(evt.ticketPrice, category.discountPercentage);
        uint256 totalPrice = unitPrice * amount;

        require(
            usdtTokenContract.transferFrom(
                msg.sender,
                address(this),
                totalPrice
            ),
            "Failed to transfer tokens"
        );

        // Referral payouts are taken out of event revenue (reduces `evt.networth`).
        uint256 referralPaid = 0;
        if (address(referralRewards) != address(0)) {
            address safeReferrer = referrer == msg.sender ? address(0) : referrer;

            (address[3] memory recipients, uint256[3] memory bonuses, uint256 paid) =
                referralRewards.processPurchase(msg.sender, safeReferrer, totalPrice);
            referralPaid = paid;

            for (uint256 i = 0; i < 3; i++) {
                if (recipients[i] != address(0) && bonuses[i] > 0) {
                    require(
                        usdtTokenContract.transfer(recipients[i], bonuses[i]),
                        "Failed to pay referral"
                    );
                }
            }
        }

        eventTicketContract.mintBatch(
            msg.sender,
            eventId,
            categoryId,
            amount,
            digest,
            hashFunction,
            size,
            referrer,
            // `pricePaid` is per-ticket. The indexer uses this to aggregate revenue.
            unitPrice
        );
        evt.customers.push(msg.sender);
        evt.networth += (totalPrice - referralPaid);
    }

    function applyDiscount(uint256 originalPrice, uint16 discountPercentage) internal pure returns (uint256) {
        if (discountPercentage == 0) {
            return originalPrice;
        }
        uint256 discount = (originalPrice * uint256(discountPercentage)) / 10000;
        return originalPrice - discount;
    }

    function closeEvent(
        uint256 eventId
    ) external onlyLocalProviderOrMaster onlyExistingEvent(eventId) {
        Event storage evt = events[eventId];
        ensureEventBelongsToProviderOrMaster(evt.eventPlaceId);
        require(
            evt.status == EventStatus.Approved,
            "Only event in approved state can be closed"
        );
        uint256 eventEndDate = calcDaysAfter(evt.startDate, evt.daysAmount);
        require(
            block.timestamp > eventEndDate,
            "Event has not been finished yet"
        );
        EventPlace storage place = eventPlaces[evt.eventPlaceId];
        // Return deposit to creator
        require(
            usdtTokenContract.transfer(evt.creator, place.eventDepositSize),
            "Failed to return deposit to creator"
        );
        // Distribute ticket revenue only if there is revenue
        if (evt.networth > 0) {
            distributeEventFunds(eventId, evt.networth);
        }
        evt.status = EventStatus.Closed;
        emit EventStatusChanged(eventId, evt.status);
    }

    function cancelEvent(
        uint256 eventId
    ) external onlyLocalProviderOrMaster onlyExistingEvent(eventId) {
        Event storage evt = events[eventId];
        ensureEventBelongsToProviderOrMaster(evt.eventPlaceId);
        require(
            evt.status == EventStatus.Approved,
            "Only event in approved state can be cancelled"
        );

        EventPlace storage place = eventPlaces[evt.eventPlaceId];
        
        // Deposit goes to shaman (event creator)
        if (place.eventDepositSize > 0) {
            require(
                usdtTokenContract.transfer(evt.creator, place.eventDepositSize),
                "Failed to refund deposit to creator"
            );
        }
        
        // Ticket revenue goes to local provider
        if (evt.networth > 0) {
            require(
                usdtTokenContract.transfer(place.provider, evt.networth),
                "Failed to transfer ticket revenue to provider"
            );
        }

        evt.networth = 0;
        evt.status = EventStatus.Cancelled;
        emit EventStatusChanged(eventId, evt.status);
    }

    // TODO: Publish event with funds distribution
    function distributeEventFunds(
        uint256 eventId,
        uint256 totalAmount
    ) internal {
        require(revenueSplitter != address(0), "Revenue splitter not set");
        usdtTokenContract.approve(revenueSplitter, totalAmount);
        IDynamicRevenueSplitter(revenueSplitter).distributeRevenue(totalAmount, eventId);
    }

    function floorTimestampToDate(
        uint256 timestamp
    ) internal pure returns (uint256) {
        return (timestamp / SECONDS_IN_DAY) * SECONDS_IN_DAY;
    }

    function calcDaysAfter(
        uint256 date,
        uint256 daysAmount
    ) internal pure returns (uint256) {
        return date + (daysAmount - 1) * SECONDS_IN_DAY;
    }

    function calcDaysBefore(
        uint256 date,
        uint256 daysAmount
    ) internal pure returns (uint256) {
        return date - (daysAmount - 1) * SECONDS_IN_DAY;
    }

    function _eventHasUnlimitedCategory(uint256 eventId) internal view returns (bool) {
        uint256[] storage eventCategories = categoryCounters[eventId];
        for (uint256 i = 0; i < eventCategories.length; i++) {
            if (!categories[eventCategories[i]].hasQuota) {
                return true;
            }
        }
        return false;
    }

    function ensureEventBelongsToProvider(uint256 eventPlaceId) internal view {
        require(
            eventPlaces[eventPlaceId].provider == msg.sender,
           "Given event belongs to another provider"
        );
    }

    function ensureEventBelongsToProviderOrMaster(uint256 eventPlaceId) internal view {
        if (hasRole(MASTER_ROLE, msg.sender)) {
            return;
        }
        ensureEventBelongsToProvider(eventPlaceId);
    }

    // Internal helper to create a category for an event
    function _createCategoryForEvent(
        uint256 eventId,
        string memory name,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota
    ) internal {
        Event storage evt = events[eventId];
        require(evt.status == EventStatus.Submitted, "Event must be in submitted state");
        require(discountPercentage <= 10000, "Discount too high");

        // Gas-efficient quota validation using stored totalCategoryQuota
        if (hasQuota) {
            require(quota > 0, "Quota must be greater than 0");
            EventPlace storage place = eventPlaces[evt.eventPlaceId];
            require(quota <= place.maxTickets, "Quota exceeds event capacity");
            require(evt.totalCategoryQuota + quota <= place.maxTickets,
                "Total category quotas exceed event capacity");
            // Update stored total
            evt.totalCategoryQuota += quota;
        } else {
            require(
                !_eventHasUnlimitedCategory(eventId),
                "Only one unlimited quota category allowed"
            );
        }

        categories.push(TicketCategory({
            name: name,
            eventId: eventId,
            discountPercentage: discountPercentage,
            quota: quota,
            hasQuota: hasQuota,
            sold: 0
        }));

        uint256 categoryId = categories.length - 1;
        categoryCounters[eventId].push(categoryId);

        emit TicketCategoryCreated(
            categoryId,
            eventId,
            name,
            discountPercentage,
            quota,
            hasQuota
        );
    }

    function createCategory(
        uint256 eventId,
        string memory name,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota
    ) external onlyRole(VERIFIED_SHAMAN_ROLE) {
        require(eventId < events.length, "Event does not exist");
        Event storage evt = events[eventId];
        // Only event creator can add categories (or local provider for any event)
        require(
            evt.creator == msg.sender || hasRole(LOCAL_PROVIDER_ROLE, msg.sender),
            "Only event creator or local provider can add categories"
        );
        _createCategoryForEvent(eventId, name, discountPercentage, quota, hasQuota);
    }

    function updateCategory(
        uint256 categoryId,
        string memory name,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota
    ) external onlyRole(LOCAL_PROVIDER_ROLE) {
        require(categoryId < categories.length, "Category does not exist");
        require(discountPercentage <= 10000, "Discount too high");
        TicketCategory storage category = categories[categoryId];

        Event storage evt = events[category.eventId];
        EventPlace storage place = eventPlaces[evt.eventPlaceId];
        require(
            place.provider == msg.sender,
            "Only the local provider of the event can update categories"
        );
        require(evt.status == EventStatus.Submitted, "Event must be in submitted state");

        // Gas-efficient quota validation using stored totalCategoryQuota
        if (hasQuota) {
            require(quota > 0, "Quota must be greater than 0");
            require(quota >= category.sold, "Quota cannot be less than tickets already sold");
            require(quota <= place.maxTickets, "Quota exceeds event capacity");

            // Calculate new total: subtract old quota, add new quota
            uint256 newTotalQuota = evt.totalCategoryQuota;
            if (category.hasQuota) {
                newTotalQuota -= category.quota;
            }
            newTotalQuota += quota;
            require(newTotalQuota <= place.maxTickets,
                "Total category quotas exceed event capacity");

            // Update stored total
            evt.totalCategoryQuota = newTotalQuota;
        } else {
            require(category.hasQuota == false, "Cannot change from limited to unlimited quota");
        }

        category.name = name;
        category.discountPercentage = discountPercentage;
        category.quota = quota;
        category.hasQuota = hasQuota;

        emit TicketCategoryUpdated(
            categoryId,
            category.eventId,
            name,
            discountPercentage,
            quota,
            hasQuota
        );
    }

    function getCategory(uint256 categoryId) external view returns (
        string memory name,
        uint256 eventId,
        uint16 discountPercentage,
        uint16 quota,
        bool hasQuota,
        uint16 sold
    ) {
        require(categoryId < categories.length, "Category does not exist");
        TicketCategory storage category = categories[categoryId];
        return (category.name, category.eventId, category.discountPercentage, category.quota, category.hasQuota, category.sold);
    }

    function getCategoriesForEvent(uint256 eventId) external view returns (uint256[] memory) {
        return categoryCounters[eventId];
    }

    function getEventsCount() external view returns (uint256) {
        return events.length;
    }
}

Homonyms

cyberia/research/events/backend/ethereum_artifacts/contracts/CyberValleyEventManager.sol
CyberValleyEventManager.sol

Graph