Source Code

;; title: subscription-manager
;; version: 1.0.0
;; summary: Recurring campaign automation and subscription management
;; description: Auto-renewal, payment schedules, subscription analytics, and failure handling

;; constants
(define-constant contract-owner tx-sender)
(define-constant err-owner-only (err u100))
(define-constant err-not-found (err u101))
(define-constant err-unauthorized (err u102))
(define-constant err-inactive-subscription (err u103))
(define-constant err-insufficient-balance (err u104))
(define-constant err-invalid-interval (err u105))
(define-constant err-already-exists (err u106))
(define-constant err-max-retries (err u107))

;; Subscription statuses
(define-constant STATUS-ACTIVE u1)
(define-constant STATUS-PAUSED u2)
(define-constant STATUS-CANCELLED u3)
(define-constant STATUS-EXPIRED u4)

;; Billing intervals (in seconds)
(define-constant INTERVAL-WEEKLY u604800)      ;; 7 days
(define-constant INTERVAL-MONTHLY u2592000)    ;; 30 days
(define-constant INTERVAL-QUARTERLY u7776000)  ;; 90 days
(define-constant INTERVAL-YEARLY u31536000)    ;; 365 days

;; data vars
(define-data-var subscription-nonce uint u0)
(define-data-var max-retry-attempts uint u3)
(define-data-var grace-period uint u259200) ;; 3 days
(define-data-var auto-renewal-enabled bool true)

;; data maps
(define-map subscriptions
    { subscription-id: uint }
    {
        campaign-id: uint,
        subscriber: principal,
        amount: uint,
        billing-interval: uint,
        status: uint,
        next-billing: uint,
        created-at: uint,
        last-payment: uint,
        total-payments: uint,
        failed-attempts: uint,
        auto-renew: bool
    }
)

(define-map subscription-plans
    { plan-id: uint }
    {
        name: (string-utf8 100),
        description: (string-utf8 500),
        price: uint,
        interval: uint,
        features: (list 10 (string-utf8 100)),
        active: bool,
        discount-percent: uint
    }
)

(define-map subscriber-to-subscription
    { subscriber: principal, campaign-id: uint }
    { subscription-id: uint }
)

(define-map payment-history
    { subscription-id: uint, payment-id: uint }
    {
        amount: uint,
        timestamp: uint,
        success: bool,
        failure-reason: (optional (string-utf8 200))
    }
)

(define-map subscription-analytics
    { subscription-id: uint }
    {
        total-revenue: uint,
        successful-payments: uint,
        failed-payments: uint,
        avg-payment-amount: uint,
        lifetime-value: uint,
        churn-risk-score: uint
    }
)

(define-map renewal-queue
    { queue-index: uint }
    {
        subscription-id: uint,
        scheduled-for: uint,
        processed: bool
    }
)

(define-map subscriber-preferences
    { subscriber: principal }
    {
        payment-method: uint,
        notification-enabled: bool,
        auto-renew-default: bool,
        spending-limit: uint
    }
)

;; read only functions
(define-read-only (get-subscription (subscription-id uint))
    (map-get? subscriptions { subscription-id: subscription-id })
)

(define-read-only (get-plan (plan-id uint))
    (map-get? subscription-plans { plan-id: plan-id })
)

(define-read-only (get-subscriber-subscription (subscriber principal) (campaign-id uint))
    (match (map-get? subscriber-to-subscription { subscriber: subscriber, campaign-id: campaign-id })
        mapping (map-get? subscriptions { subscription-id: (get subscription-id mapping) })
        none
    )
)

(define-read-only (get-payment-history (subscription-id uint) (payment-id uint))
    (map-get? payment-history { subscription-id: subscription-id, payment-id: payment-id })
)

(define-read-only (get-subscription-analytics (subscription-id uint))
    (map-get? subscription-analytics { subscription-id: subscription-id })
)

(define-read-only (get-subscriber-preferences (subscriber principal))
    (map-get? subscriber-preferences { subscriber: subscriber })
)

(define-read-only (is-subscription-active (subscription-id uint))
    (match (map-get? subscriptions { subscription-id: subscription-id })
        sub-data (is-eq (get status sub-data) STATUS-ACTIVE)
        false
    )
)

(define-read-only (get-renewal-status (subscription-id uint))
    (match (map-get? subscriptions { subscription-id: subscription-id })
        sub-data {
            active: (is-eq (get status sub-data) STATUS-ACTIVE),
            next-billing: (get next-billing sub-data),
            auto-renew: (get auto-renew sub-data),
            failed-attempts: (get failed-attempts sub-data)
        }
        { active: false, next-billing: u0, auto-renew: false, failed-attempts: u0 }
    )
)

(define-read-only (calculate-next-billing (current-time uint) (interval uint))
    (+ current-time interval)
)

;; public functions
(define-public (create-subscription
    (campaign-id uint)
    (amount uint)
    (billing-interval uint)
    (auto-renew bool)
)
    (let
        (
            (subscription-id (+ (var-get subscription-nonce) u1))
            (next-billing-time (calculate-next-billing stacks-block-time billing-interval))
        )
        (asserts! (is-valid-interval billing-interval) err-invalid-interval)
        (asserts! (is-none (map-get? subscriber-to-subscription { subscriber: tx-sender, campaign-id: campaign-id })) err-already-exists)

        (map-set subscriptions
            { subscription-id: subscription-id }
            {
                campaign-id: campaign-id,
                subscriber: tx-sender,
                amount: amount,
                billing-interval: billing-interval,
                status: STATUS-ACTIVE,
                next-billing: next-billing-time,
                created-at: stacks-block-time,
                last-payment: stacks-block-time,
                total-payments: u1,
                failed-attempts: u0,
                auto-renew: auto-renew
            }
        )

        (map-set subscriber-to-subscription
            { subscriber: tx-sender, campaign-id: campaign-id }
            { subscription-id: subscription-id }
        )

        (map-set subscription-analytics
            { subscription-id: subscription-id }
            {
                total-revenue: amount,
                successful-payments: u1,
                failed-payments: u0,
                avg-payment-amount: amount,
                lifetime-value: amount,
                churn-risk-score: u0
            }
        )

        (var-set subscription-nonce subscription-id)
        (ok subscription-id)
    )
)

(define-public (cancel-subscription (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender (get subscriber sub-data)) err-unauthorized)
        (asserts! (is-eq (get status sub-data) STATUS-ACTIVE) err-inactive-subscription)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { status: STATUS-CANCELLED })
        )

        (ok true)
    )
)

(define-public (pause-subscription (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender (get subscriber sub-data)) err-unauthorized)
        (asserts! (is-eq (get status sub-data) STATUS-ACTIVE) err-inactive-subscription)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { status: STATUS-PAUSED })
        )

        (ok true)
    )
)

(define-public (resume-subscription (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender (get subscriber sub-data)) err-unauthorized)
        (asserts! (is-eq (get status sub-data) STATUS-PAUSED) err-inactive-subscription)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { status: STATUS-ACTIVE })
        )

        (ok true)
    )
)

(define-public (process-renewal (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
            (analytics (unwrap! (map-get? subscription-analytics { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq (get status sub-data) STATUS-ACTIVE) err-inactive-subscription)
        (asserts! (<= (get next-billing sub-data) stacks-block-time) err-unauthorized)
        (asserts! (get auto-renew sub-data) err-unauthorized)

        ;; In production, process payment
        ;; (try! (stx-transfer? (get amount sub-data) (get subscriber sub-data) contract-owner))

        (let
            (
                (new-total-payments (+ (get total-payments sub-data) u1))
                (new-revenue (+ (get total-revenue analytics) (get amount sub-data)))
                (new-successful (+ (get successful-payments analytics) u1))
                (new-avg (/ new-revenue new-total-payments))
            )
            (map-set subscriptions
                { subscription-id: subscription-id }
                (merge sub-data {
                    next-billing: (calculate-next-billing stacks-block-time (get billing-interval sub-data)),
                    last-payment: stacks-block-time,
                    total-payments: new-total-payments,
                    failed-attempts: u0
                })
            )

            (map-set subscription-analytics
                { subscription-id: subscription-id }
                (merge analytics {
                    total-revenue: new-revenue,
                    successful-payments: new-successful,
                    avg-payment-amount: new-avg,
                    lifetime-value: new-revenue
                })
            )

            (ok true)
        )
    )
)

(define-public (handle-payment-failure
    (subscription-id uint)
    (failure-reason (string-utf8 200))
)
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
            (analytics (unwrap! (map-get? subscription-analytics { subscription-id: subscription-id }) err-not-found))
            (new-failed-attempts (+ (get failed-attempts sub-data) u1))
        )
        (asserts! (is-eq tx-sender contract-owner) err-owner-only)

        (map-set subscription-analytics
            { subscription-id: subscription-id }
            (merge analytics {
                failed-payments: (+ (get failed-payments analytics) u1),
                churn-risk-score: (* new-failed-attempts u20)
            })
        )

        (if (>= new-failed-attempts (var-get max-retry-attempts))
            (begin
                (map-set subscriptions
                    { subscription-id: subscription-id }
                    (merge sub-data {
                        status: STATUS-CANCELLED,
                        failed-attempts: new-failed-attempts
                    })
                )
                (ok false)
            )
            (begin
                (map-set subscriptions
                    { subscription-id: subscription-id }
                    (merge sub-data {
                        failed-attempts: new-failed-attempts,
                        next-billing: (+ (get next-billing sub-data) (var-get grace-period))
                    })
                )
                (ok true)
            )
        )
    )
)

(define-public (update-subscription-amount (subscription-id uint) (new-amount uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender (get subscriber sub-data)) err-unauthorized)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { amount: new-amount })
        )

        (ok true)
    )
)

(define-public (toggle-auto-renew (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender (get subscriber sub-data)) err-unauthorized)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { auto-renew: (not (get auto-renew sub-data)) })
        )

        (ok (not (get auto-renew sub-data)))
    )
)

(define-public (create-subscription-plan
    (name (string-utf8 100))
    (description (string-utf8 500))
    (price uint)
    (interval uint)
    (features (list 10 (string-utf8 100)))
    (discount-percent uint)
)
    (let
        (
            (plan-id (+ (var-get subscription-nonce) u1))
        )
        (asserts! (is-eq tx-sender contract-owner) err-owner-only)
        (asserts! (is-valid-interval interval) err-invalid-interval)

        (map-set subscription-plans
            { plan-id: plan-id }
            {
                name: name,
                description: description,
                price: price,
                interval: interval,
                features: features,
                active: true,
                discount-percent: discount-percent
            }
        )

        (ok plan-id)
    )
)

(define-public (set-subscriber-preferences
    (payment-method uint)
    (notification-enabled bool)
    (auto-renew-default bool)
    (spending-limit uint)
)
    (begin
        (map-set subscriber-preferences
            { subscriber: tx-sender }
            {
                payment-method: payment-method,
                notification-enabled: notification-enabled,
                auto-renew-default: auto-renew-default,
                spending-limit: spending-limit
            }
        )

        (ok true)
    )
)

;; Admin functions
(define-public (set-max-retry-attempts (new-max uint))
    (begin
        (asserts! (is-eq tx-sender contract-owner) err-owner-only)
        (var-set max-retry-attempts new-max)
        (ok true)
    )
)

(define-public (set-grace-period (new-period uint))
    (begin
        (asserts! (is-eq tx-sender contract-owner) err-owner-only)
        (var-set grace-period new-period)
        (ok true)
    )
)

(define-public (expire-subscription (subscription-id uint))
    (let
        (
            (sub-data (unwrap! (map-get? subscriptions { subscription-id: subscription-id }) err-not-found))
        )
        (asserts! (is-eq tx-sender contract-owner) err-owner-only)

        (map-set subscriptions
            { subscription-id: subscription-id }
            (merge sub-data { status: STATUS-EXPIRED })
        )

        (ok true)
    )
)

;; private functions
(define-private (is-valid-interval (interval uint))
    (or
        (is-eq interval INTERVAL-WEEKLY)
        (or
            (is-eq interval INTERVAL-MONTHLY)
            (or
                (is-eq interval INTERVAL-QUARTERLY)
                (is-eq interval INTERVAL-YEARLY)
            )
        )
    )
)

Functions (21)

FunctionAccessArgs
get-subscriptionread-onlysubscription-id: uint
get-planread-onlyplan-id: uint
get-subscriber-subscriptionread-onlysubscriber: principal, campaign-id: uint
get-payment-historyread-onlysubscription-id: uint, payment-id: uint
get-subscription-analyticsread-onlysubscription-id: uint
get-subscriber-preferencesread-onlysubscriber: principal
is-subscription-activeread-onlysubscription-id: uint
get-renewal-statusread-onlysubscription-id: uint
calculate-next-billingread-onlycurrent-time: uint, interval: uint
cancel-subscriptionpublicsubscription-id: uint
pause-subscriptionpublicsubscription-id: uint
resume-subscriptionpublicsubscription-id: uint
process-renewalpublicsubscription-id: uint
handle-payment-failurepublicsubscription-id: uint, failure-reason: (string-utf8 200
update-subscription-amountpublicsubscription-id: uint, new-amount: uint
toggle-auto-renewpublicsubscription-id: uint
create-subscription-planpublicname: (string-utf8 100
set-max-retry-attemptspublicnew-max: uint
set-grace-periodpublicnew-period: uint
expire-subscriptionpublicsubscription-id: uint
is-valid-intervalprivateinterval: uint