// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @title SubscriptionManager /// @author sigmacode.io (Sanicura d.o.o.) /// @notice Recurring ERC-20 subscriptions ("crypto SaaS billing") with built-in revenue splitting. /// @dev Pull-payment model: /// 1. A merchant creates a plan: token, price per period, period length and up to {MAX_PAYEES} /// revenue-share payees in basis points. The merchant receives the remainder. /// 2. A subscriber approves the token and calls {subscribe}; the first period is charged immediately. /// 3. Anyone (merchant backend, keeper, the subscriber) calls {charge} once a period is due. /// Funds flow directly from the subscriber to the merchant and payees; this contract never /// holds a balance. /// 4. Subscriber or merchant can {cancel} at any time; the merchant can deactivate a plan. /// /// No back-billing: if a subscription is overdue by more than one period, a single period is /// charged and the schedule restarts from the current time. /// Fee-on-transfer and rebasing tokens are not supported. contract SubscriptionManager is ReentrancyGuard { using SafeERC20 for IERC20; /// @notice Basis point denominator (100% = 10_000). uint256 public constant BPS_DENOMINATOR = 10_000; /// @notice Maximum revenue-share payees per plan (excluding the merchant). uint256 public constant MAX_PAYEES = 5; /// @notice Minimum billing period, protects subscribers from rapid-fire charges. uint256 public constant MIN_PERIOD = 1 hours; /// @notice A subscription plan. struct Plan { address merchant; address token; uint96 price; uint32 period; bool active; address[] payees; uint16[] sharesBps; } /// @notice A subscriber's subscription to a plan. struct Subscription { uint256 planId; address subscriber; uint64 nextChargeAt; bool active; } /// @notice Number of plans. Plan ids are `1..planCount`. uint256 public planCount; /// @notice Number of subscriptions. Subscription ids are `1..subscriptionCount`. uint256 public subscriptionCount; mapping(uint256 planId => Plan) private _plans; mapping(uint256 subscriptionId => Subscription) private _subscriptions; /// @notice Active subscription id of a subscriber for a plan (0 = none). mapping(uint256 planId => mapping(address subscriber => uint256 subscriptionId)) public activeSubscriptionOf; /// @notice Emitted when a plan is created. event PlanCreated( uint256 indexed planId, address indexed merchant, address indexed token, uint256 price, uint256 period, address[] payees, uint16[] sharesBps ); /// @notice Emitted when a plan is activated or deactivated. event PlanStatusChanged(uint256 indexed planId, bool active); /// @notice Emitted when a subscription starts. event Subscribed(uint256 indexed subscriptionId, uint256 indexed planId, address indexed subscriber); /// @notice Emitted on every successful charge. event Charged( uint256 indexed subscriptionId, uint256 indexed planId, address indexed subscriber, uint256 amount, uint64 nextChargeAt ); /// @notice Emitted when a subscription is cancelled. event SubscriptionCancelled(uint256 indexed subscriptionId, address indexed cancelledBy); error ZeroAddress(); error ZeroPrice(); error PeriodTooShort(uint256 period); error TooManyPayees(uint256 count); error LengthMismatch(); error InvalidShares(uint256 totalBps); error PlanNotFound(uint256 planId); error PlanInactive(uint256 planId); error NotMerchant(); error AlreadySubscribed(uint256 subscriptionId); error SubscriptionNotActive(uint256 subscriptionId); error ChargeNotDue(uint64 nextChargeAt); error Unauthorized(); // --------------------------------------------------------------------- // Merchant // --------------------------------------------------------------------- /// @notice Creates a subscription plan owned by `msg.sender`. /// @param token ERC-20 used for billing. /// @param price Amount charged per period. /// @param period Billing period in seconds (>= {MIN_PERIOD}). /// @param payees Revenue-share recipients (<= {MAX_PAYEES}). /// @param sharesBps Share of each payee in basis points; the merchant receives `10_000 - sum`. /// @return planId Identifier of the new plan. function createPlan( address token, uint96 price, uint32 period, address[] calldata payees, uint16[] calldata sharesBps ) external returns (uint256 planId) { if (token == address(0)) revert ZeroAddress(); if (price == 0) revert ZeroPrice(); if (period < MIN_PERIOD) revert PeriodTooShort(period); if (payees.length > MAX_PAYEES) revert TooManyPayees(payees.length); if (payees.length != sharesBps.length) revert LengthMismatch(); uint256 totalBps; for (uint256 i; i < payees.length; ++i) { if (payees[i] == address(0)) revert ZeroAddress(); totalBps += sharesBps[i]; } if (totalBps > BPS_DENOMINATOR) revert InvalidShares(totalBps); planId = ++planCount; _plans[planId] = Plan({ merchant: msg.sender, token: token, price: price, period: period, active: true, payees: payees, sharesBps: sharesBps }); emit PlanCreated(planId, msg.sender, token, price, period, payees, sharesBps); } /// @notice Activates or deactivates a plan. Inactive plans accept no new subscriptions and no charges. function setPlanActive(uint256 planId, bool active) external { Plan storage plan = _existingPlan(planId); if (msg.sender != plan.merchant) revert NotMerchant(); plan.active = active; emit PlanStatusChanged(planId, active); } // --------------------------------------------------------------------- // Subscriber // --------------------------------------------------------------------- /// @notice Subscribes `msg.sender` to a plan and charges the first period immediately. /// @dev Requires an ERC-20 allowance of at least `price` (ideally `price * expected periods`). /// @param planId Plan identifier. /// @return subscriptionId Identifier of the new subscription. function subscribe(uint256 planId) external nonReentrant returns (uint256 subscriptionId) { Plan storage plan = _existingPlan(planId); if (!plan.active) revert PlanInactive(planId); uint256 existing = activeSubscriptionOf[planId][msg.sender]; if (existing != 0) revert AlreadySubscribed(existing); subscriptionId = ++subscriptionCount; uint64 next = uint64(block.timestamp) + plan.period; _subscriptions[subscriptionId] = Subscription({planId: planId, subscriber: msg.sender, nextChargeAt: next, active: true}); activeSubscriptionOf[planId][msg.sender] = subscriptionId; emit Subscribed(subscriptionId, planId, msg.sender); emit Charged(subscriptionId, planId, msg.sender, plan.price, next); _distribute(plan, msg.sender); } /// @notice Charges one due period of a subscription. Callable by anyone. /// @param subscriptionId Subscription identifier. function charge(uint256 subscriptionId) external nonReentrant { Subscription storage sub = _subscriptions[subscriptionId]; if (!sub.active) revert SubscriptionNotActive(subscriptionId); if (block.timestamp < sub.nextChargeAt) revert ChargeNotDue(sub.nextChargeAt); Plan storage plan = _plans[sub.planId]; if (!plan.active) revert PlanInactive(sub.planId); uint64 next = sub.nextChargeAt + plan.period; if (next <= block.timestamp) next = uint64(block.timestamp) + plan.period; // no back-billing sub.nextChargeAt = next; emit Charged(subscriptionId, sub.planId, sub.subscriber, plan.price, next); _distribute(plan, sub.subscriber); } /// @notice Cancels a subscription. Callable by the subscriber or the plan's merchant. /// @param subscriptionId Subscription identifier. function cancel(uint256 subscriptionId) external { Subscription storage sub = _subscriptions[subscriptionId]; if (!sub.active) revert SubscriptionNotActive(subscriptionId); if (msg.sender != sub.subscriber && msg.sender != _plans[sub.planId].merchant) revert Unauthorized(); sub.active = false; delete activeSubscriptionOf[sub.planId][sub.subscriber]; emit SubscriptionCancelled(subscriptionId, msg.sender); } // --------------------------------------------------------------------- // Views // --------------------------------------------------------------------- /// @notice Returns a plan. function getPlan(uint256 planId) external view returns (Plan memory) { return _plans[planId]; } /// @notice Returns a subscription. function getSubscription(uint256 subscriptionId) external view returns (Subscription memory) { return _subscriptions[subscriptionId]; } /// @notice Whether a subscription can be charged right now. function isDue(uint256 subscriptionId) external view returns (bool) { Subscription storage sub = _subscriptions[subscriptionId]; return sub.active && _plans[sub.planId].active && block.timestamp >= sub.nextChargeAt; } /// @notice Computes the split of one charge: payee amounts and the merchant remainder. function previewSplit(uint256 planId) public view returns (uint256[] memory payeeAmounts, uint256 merchantAmount) { Plan storage plan = _existingPlan(planId); uint256 n = plan.payees.length; payeeAmounts = new uint256[](n); merchantAmount = plan.price; for (uint256 i; i < n; ++i) { uint256 share = (uint256(plan.price) * plan.sharesBps[i]) / BPS_DENOMINATOR; payeeAmounts[i] = share; merchantAmount -= share; } } // --------------------------------------------------------------------- // Internal // --------------------------------------------------------------------- function _existingPlan(uint256 planId) private view returns (Plan storage plan) { plan = _plans[planId]; if (plan.merchant == address(0)) revert PlanNotFound(planId); } /// @dev Transfers the price from `payer` directly to payees and merchant. Rounding dust goes to the merchant. function _distribute(Plan storage plan, address payer) private { IERC20 token = IERC20(plan.token); uint256 price = plan.price; uint256 remaining = price; uint256 n = plan.payees.length; for (uint256 i; i < n; ++i) { uint256 share = (price * plan.sharesBps[i]) / BPS_DENOMINATOR; if (share > 0) { remaining -= share; token.safeTransferFrom(payer, plan.payees[i], share); } } if (remaining > 0) token.safeTransferFrom(payer, plan.merchant, remaining); } }