StrimzStrimz
Subscriptions

How charges fire

The scheduler's sweeper picks due subscriptions, locks them, and a worker calls batchCharge on chain for each one. Safe with several replicas, idempotent at the contract, and it reclaims stale locks.

The scheduler has a cron called the subscription sweeper. It runs every 15 minutes by default (SUBSCRIPTION_SWEEPER_CRON). Each run it finds subscriptions that are due, locks them, and enqueues one charge job per subscription.

The sweep query

The sweeper locks its rows in a single statement:

UPDATE "Subscription"
   SET "chargeLock" = true,
       "chargeLockAcquiredAt" = NOW()
 WHERE id IN (
   SELECT id FROM "Subscription"
    WHERE (
            "chargeLock" = false
         OR "chargeLockAcquiredAt" < NOW() - INTERVAL '1 second' * $3
          )
      AND "nextChargeAt" IS NOT NULL
      AND "nextChargeAt" <= $1
      AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= $1)
      AND status IN ('trialing', 'active', 'at_risk')
      AND "onchainSubscriptionId" IS NOT NULL
    ORDER BY "nextChargeAt" ASC
    LIMIT $2
    FOR UPDATE SKIP LOCKED
 )
 RETURNING id

What each part does:

  • FOR UPDATE SKIP LOCKED lets several scheduler replicas run side by side. A row another replica is working on is skipped, so two replicas never pick up the same subscription in the same run.
  • The chargeLock clause is the stale-lock reclaim from Safety nets. $3 is 600 seconds and is fixed in code. A lock older than ten minutes is treated as abandoned.
  • nextRetryAt holds back a subscription that failed recently until its next retry time. See Recovery.
  • trialing is included so the first charge after a trial is picked up.
  • onchainSubscriptionId IS NOT NULL skips rows the indexer has not linked to the contract yet.
  • LIMIT $2 caps each run. The default is 100, set with SUBSCRIPTION_SWEEP_LIMIT.

One job per subscription

Each locked row becomes one job on the strimz.subscription.due queue, and each job charges one subscription. A bad subscription cannot hold up the others.

The worker:

  1. Releases the lock and stops if the subscription is cancelled or lapsed, or has no on-chain id.
  2. Asks the contract whether the charge is due. If not, it releases the lock and stops.
  3. Picks the first unused charge attempt id for this period (see below). If all 8 are used, it releases the lock and stops.
  4. Calls batchCharge([subscriptionId], [chargeAttemptId]).
  5. Releases the lock, whether the broadcast succeeded or failed.

The worker only broadcasts the transaction. The result is projected by the indexer.

The on-chain call

function batchCharge(
  uint256[] calldata subscriptionIds,
  bytes32[] calldata chargeAttemptIds
) external returns (ChargeOutcome[] memory outcomes);

Only the CHARGER_ROLE can call it. The scheduler holds that role.

The charge attempt id is:

keccak256(abi.encodePacked(uint256 onchainSubId, uint64 currentPeriodEndAt, uint32 attempt))

attempt runs from 0 to 7. The id is deterministic, so replaying the same attempt gives the same id. The contract records used ids. A second call with a used id does not revert: the contract emits SubscriptionChargeSkipped with outcome Duplicate and moves on. The contract marks an id used only when it attempts the transfer, so on Charged or TransferFailed. Skips before that point, such as InsufficientFunds, leave the id unused, and the next retry sends the same id again. After a TransferFailed, the next retry uses the next attempt number.

No failure reverts the call. Each subscription gets an outcome:

OutcomeMeaning
ChargedThe payer was charged
DuplicateThis attempt id was already used
UnknownNo subscription with this id
CancelledThe subscription is cancelled
NotDuenextChargeAt is still in the future
EndedThe subscription is past its end time
RevokedApprovalThe allowance is too low
InsufficientFundsThe balance is too low
MerchantInactiveThe merchant is not active in the registry
TransferFailedThe token transfer failed

If a subscription is more than one period behind, the contract charges one period and moves nextChargeAt past the missed ones. It does not charge for each missed period.

What the indexer projects

The indexer writes the SubscriptionCharge rows. There is no separate API route for listing charges; they arrive in the subscription.charged and subscription.charge_failed webhook payloads.

On SubscriptionCharged, the indexer writes a charge row with status: 'succeeded' and a transaction row, moves the period forward, resets the retry counter, sets trialing or at_risk subscriptions to active, and fires subscription.charged.

On SubscriptionChargeSkipped, the indexer writes a charge row with status: 'failed' and the outcome. Only payment failures (insufficient_funds, revoked_approval, merchant_inactive, transfer_failed) fire subscription.charge_failed and move an active or at_risk subscription to at_risk. The race-condition guard keeps a subscription that already paid this period from being downgraded. Other outcomes such as not_due and duplicate are recorded and nothing else happens. In the API, skipped is the fallback for an outcome the contract adds later.

When charges don't fire

  • nextChargeAt is in the future. This is the normal case.
  • nextRetryAt is in the future after a recent failure.
  • The subscription is cancelled or lapsed.
  • The indexer has not linked the row to its on-chain id yet.

Archiving a plan does not stop charges for existing subscribers.

The scheduler does not check its gas balance before charging. A separate cron checks the relayer and scheduler balances every 15 minutes and emails an operator when one drops below GAS_BALANCE_THRESHOLD_USDC. See Self-hosting the scheduler.

There is no call to trigger a charge on demand. The next sweep picks up anything due.

On this page