Source Code

;; CircleCare Group Treasury - Clarity 4
;; Expense Management with stacks-block-time timestamps
;; Handles expenses, settlements, and member balances for all groups

;; Error codes
(define-constant ERR-UNAUTHORIZED (err u200))
(define-constant ERR-INVALID-AMOUNT (err u201))
(define-constant ERR-INVALID-PARTICIPANT (err u202))
(define-constant ERR-EXPENSE-NOT-FOUND (err u203))
(define-constant ERR-INSUFFICIENT-PAYMENT (err u204))
(define-constant ERR-NO-DEBT (err u205))
(define-constant ERR-MEMBER-NOT-FOUND (err u206))
(define-constant ERR-INVALID-DESCRIPTION (err u207))
(define-constant ERR-GROUP-PAUSED (err u208))
(define-constant ERR-MEMBER-EXISTS (err u209))
(define-constant ERR-MAX-MEMBERS (err u210))
(define-constant ERR-MAX-PARTICIPANTS (err u211))
(define-constant ERR-GROUP-NOT-FOUND (err u212))
(define-constant ERR-DUPLICATE-PARTICIPANT (err u213))
(define-constant ERR-MEMBER-HAS-BALANCE (err u214))
(define-constant ERR-TRANSFER-FAILED (err u215))

;; Constants
(define-constant MAX-PARTICIPANTS u20)
(define-constant MAX-MEMBERS u50)
(define-constant MAX-DESCRIPTION-LENGTH u200)

;; Reference to expense factory contract
(define-data-var factory-contract principal tx-sender)

;; Data structures using maps (all indexed by group-id)

;; Group metadata
(define-map groups
  uint
  {
    name: (string-ascii 50),
    creator: principal,
    created-at: uint, ;; Unix timestamp using stacks-block-time
    paused: bool,
    next-expense-id: uint,
    next-settlement-id: uint,
    member-count: uint
  }
)

;; Members: {group-id, member} => member info
(define-map members
  {group-id: uint, member: principal}
  {
    nickname: (string-ascii 32),
    total-owed: uint,
    total-owing: uint,
    active: bool,
    joined-at: uint ;; Unix timestamp using stacks-block-time
  }
)

;; Expenses: {group-id, expense-id} => expense info
(define-map expenses
  {group-id: uint, expense-id: uint}
  {
    description: (string-ascii 200),
    total-amount: uint,
    paid-by: principal,
    settled: bool,
    timestamp: uint, ;; Unix timestamp using stacks-block-time
    participant-count: uint
  }
)

;; Expense participants: {group-id, expense-id, index} => {participant, share}
(define-map expense-participants
  {group-id: uint, expense-id: uint, index: uint}
  {participant: principal, share: uint}
)

;; Balances: {group-id, debtor, creditor} => amount
(define-map balances
  {group-id: uint, debtor: principal, creditor: principal}
  uint
)

;; Settlements: {group-id, settlement-id} => settlement info
(define-map settlements
  {group-id: uint, settlement-id: uint}
  {
    debtor: principal,
    creditor: principal,
    amount: uint,
    timestamp: uint ;; Unix timestamp using stacks-block-time
  }
)

;; Member list for each group: {group-id, index} => principal
(define-map group-member-list
  {group-id: uint, index: uint}
  principal
)

;; Public Functions

;; Initialize a group (called by factory or creator)
(define-public (initialize-group (group-id uint) (name (string-ascii 50)) (creator principal) (creator-nickname (string-ascii 32)))
  (begin
    ;; Check that group doesn't already exist
    (asserts! (is-none (map-get? groups group-id)) ERR-GROUP-NOT-FOUND)

    ;; Create group with stacks-block-time
    (map-set groups group-id {
      name: name,
      creator: creator,
      created-at: stacks-block-time, ;; Clarity 4: Unix timestamp
      paused: false,
      next-expense-id: u1,
      next-settlement-id: u1,
      member-count: u1
    })

    ;; Add creator as first member with stacks-block-time
    (map-set members {group-id: group-id, member: creator} {
      nickname: creator-nickname,
      total-owed: u0,
      total-owing: u0,
      active: true,
      joined-at: stacks-block-time ;; Clarity 4: Unix timestamp
    })

    ;; Add to member list
    (map-set group-member-list {group-id: group-id, index: u0} creator)

    (print {
      event: "group-initialized",
      group-id: group-id,
      name: name,
      creator: creator,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Add member to group (only group creator)
(define-public (add-member (group-id uint) (member principal) (nickname (string-ascii 32)))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
      (member-key {group-id: group-id, member: member})
    )
    ;; Validations
    (asserts! (is-eq tx-sender (get creator group)) ERR-UNAUTHORIZED)
    (asserts! (not (get paused group)) ERR-GROUP-PAUSED)
    (asserts! (< (get member-count group) MAX-MEMBERS) ERR-MAX-MEMBERS)
    (asserts! (> (len nickname) u0) ERR-INVALID-DESCRIPTION)
    (asserts! (<= (len nickname) u32) ERR-INVALID-DESCRIPTION)

    ;; Check member doesn't exist
    (asserts! (is-none (map-get? members member-key)) ERR-MEMBER-EXISTS)

    ;; Add member with stacks-block-time
    (map-set members member-key {
      nickname: nickname,
      total-owed: u0,
      total-owing: u0,
      active: true,
      joined-at: stacks-block-time ;; Clarity 4: Unix timestamp
    })

    ;; Add to member list
    (map-set group-member-list
      {group-id: group-id, index: (get member-count group)}
      member)

    ;; Update group member count
    (map-set groups group-id (merge group {
      member-count: (+ (get member-count group) u1)
    }))

    (print {
      event: "member-added",
      group-id: group-id,
      member: member,
      nickname: nickname,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Add expense with STX/sBTC amounts
(define-public (add-expense
  (group-id uint)
  (description (string-ascii 200))
  (amount uint)
  (participants (list 20 principal)))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
      (expense-id (get next-expense-id group))
      (participant-count (len participants))
      (share-per-person (/ amount participant-count))
      (remainder (mod amount participant-count))
      (member-key {group-id: group-id, member: tx-sender})
    )
    ;; Validations
    (asserts! (not (get paused group)) ERR-GROUP-PAUSED)
    (asserts! (> amount u0) ERR-INVALID-AMOUNT)
    (asserts! (> participant-count u0) ERR-INVALID-PARTICIPANT)
    (asserts! (<= participant-count MAX-PARTICIPANTS) ERR-MAX-PARTICIPANTS)
    (asserts! (> (len description) u0) ERR-INVALID-DESCRIPTION)
    (asserts! (<= (len description) MAX-DESCRIPTION-LENGTH) ERR-INVALID-DESCRIPTION)

    ;; Check sender is active member
    (asserts! (get active (unwrap! (map-get? members member-key) ERR-MEMBER-NOT-FOUND)) ERR-UNAUTHORIZED)

    ;; Validate all participants are active members and check for duplicates
    (try! (validate-participants group-id participants))

    ;; Store expense with stacks-block-time
    (map-set expenses {group-id: group-id, expense-id: expense-id} {
      description: description,
      total-amount: amount,
      paid-by: tx-sender,
      settled: false,
      timestamp: stacks-block-time, ;; Clarity 4: Unix timestamp
      participant-count: participant-count
    })

    ;; Store participants and their shares, and update balances
    (try! (process-participants group-id expense-id participants share-per-person remainder))

    ;; Update next expense ID
    (map-set groups group-id (merge group {
      next-expense-id: (+ expense-id u1)
    }))

    (print {
      event: "expense-added",
      group-id: group-id,
      expense-id: expense-id,
      paid-by: tx-sender,
      amount: amount,
      description: description,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok expense-id)
  )
)

;; Settle debt in STX
(define-public (settle-debt-stx (group-id uint) (creditor principal) (amount uint))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
      (balance-key {group-id: group-id, debtor: tx-sender, creditor: creditor})
      (debt (default-to u0 (map-get? balances balance-key)))
      (settlement-id (get next-settlement-id group))
      (debtor-key {group-id: group-id, member: tx-sender})
      (creditor-key {group-id: group-id, member: creditor})
      (debtor-info (unwrap! (map-get? members debtor-key) ERR-MEMBER-NOT-FOUND))
      (creditor-info (unwrap! (map-get? members creditor-key) ERR-MEMBER-NOT-FOUND))
    )
    ;; Validations
    (asserts! (not (get paused group)) ERR-GROUP-PAUSED)
    (asserts! (> debt u0) ERR-NO-DEBT)
    (asserts! (>= amount debt) ERR-INSUFFICIENT-PAYMENT)
    (asserts! (get active debtor-info) ERR-UNAUTHORIZED)
    (asserts! (get active creditor-info) ERR-MEMBER-NOT-FOUND)

    ;; Transfer STX
    (try! (stx-transfer? debt tx-sender creditor))

    ;; Update balances
    (map-delete balances balance-key)

    ;; Update member totals
    (map-set members debtor-key (merge debtor-info {
      total-owed: (- (get total-owed debtor-info) debt)
    }))
    (map-set members creditor-key (merge creditor-info {
      total-owing: (- (get total-owing creditor-info) debt)
    }))

    ;; Record settlement with stacks-block-time
    (map-set settlements {group-id: group-id, settlement-id: settlement-id} {
      debtor: tx-sender,
      creditor: creditor,
      amount: debt,
      timestamp: stacks-block-time ;; Clarity 4: Unix timestamp
    })

    ;; Update next settlement ID
    (map-set groups group-id (merge group {
      next-settlement-id: (+ settlement-id u1)
    }))

    (print {
      event: "debt-settled-stx",
      group-id: group-id,
      debtor: tx-sender,
      creditor: creditor,
      amount: debt,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok settlement-id)
  )
)

;; Pause group (only creator)
(define-public (pause-group (group-id uint))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
    )
    (asserts! (is-eq tx-sender (get creator group)) ERR-UNAUTHORIZED)
    (map-set groups group-id (merge group {paused: true}))
    (print {
      event: "group-paused",
      group-id: group-id,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Unpause group (only creator)
(define-public (unpause-group (group-id uint))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
    )
    (asserts! (is-eq tx-sender (get creator group)) ERR-UNAUTHORIZED)
    (map-set groups group-id (merge group {paused: false}))
    (print {
      event: "group-unpaused",
      group-id: group-id,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Update expense description (only creator of expense)
(define-public (update-expense-description (group-id uint) (expense-id uint) (new-description (string-ascii 200)))
  (let
    (
      (expense-key {group-id: group-id, expense-id: expense-id})
      (expense (unwrap! (map-get? expenses expense-key) ERR-EXPENSE-NOT-FOUND))
    )
    (asserts! (is-eq tx-sender (get paid-by expense)) ERR-UNAUTHORIZED)
    (asserts! (not (get settled expense)) ERR-UNAUTHORIZED)
    (asserts! (> (len new-description) u0) ERR-INVALID-DESCRIPTION)
    (asserts! (<= (len new-description) MAX-DESCRIPTION-LENGTH) ERR-INVALID-DESCRIPTION)

    (map-set expenses expense-key (merge expense {description: new-description}))
    (print {
      event: "expense-updated",
      group-id: group-id,
      expense-id: expense-id,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Remove member (only creator, member must have zero balance)
(define-public (remove-member (group-id uint) (member principal))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
      (member-key {group-id: group-id, member: member})
      (member-info (unwrap! (map-get? members member-key) ERR-MEMBER-NOT-FOUND))
    )
    (asserts! (is-eq tx-sender (get creator group)) ERR-UNAUTHORIZED)
    (asserts! (not (is-eq member (get creator group))) ERR-UNAUTHORIZED)
    (asserts! (is-eq (get total-owed member-info) u0) ERR-MEMBER-HAS-BALANCE)
    (asserts! (is-eq (get total-owing member-info) u0) ERR-MEMBER-HAS-BALANCE)

    ;; Deactivate member
    (map-set members member-key (merge member-info {active: false}))

    (print {
      event: "member-removed",
      group-id: group-id,
      member: member,
      timestamp: stacks-block-time ;; Clarity 4
    })
    (ok true)
  )
)

;; Helper functions (private)

;; Validate all participants are active members
(define-private (validate-participants (group-id uint) (participants (list 20 principal)))
  (fold validate-participant participants (ok group-id))
)

(define-private (validate-participant (participant principal) (acc (response uint uint)))
  (match acc
    success
      (let
        (
          (member-key {group-id: success, member: participant})
          (member-info (map-get? members member-key))
        )
        (if (and (is-some member-info) (get active (unwrap-panic member-info)))
          (ok success)
          ERR-INVALID-PARTICIPANT
        )
      )
    error (err error)
  )
)

;; Process participants and update balances (simplified to avoid circular dependencies)
(define-private (process-participants
  (group-id uint)
  (expense-id uint)
  (participants (list 20 principal))
  (base-share uint)
  (remainder uint))
  (let
    (
      (paid-by (get paid-by (unwrap-panic (map-get? expenses {group-id: group-id, expense-id: expense-id}))))
      (context {
        group-id: group-id,
        expense-id: expense-id,
        base-share: base-share,
        remainder: remainder,
        paid-by: paid-by,
        current-index: u0
      })
    )
    (fold process-single-participant participants (ok context))
  )
)

(define-private (process-single-participant
  (participant principal)
  (acc (response {group-id: uint, expense-id: uint, base-share: uint, remainder: uint, paid-by: principal, current-index: uint} uint)))
  (match acc
    success
      (let
        (
          (index (get current-index success))
          (group-id (get group-id success))
          (expense-id (get expense-id success))
          (base-share (get base-share success))
          (remainder (get remainder success))
          (paid-by (get paid-by success))
          (share (if (< index remainder) (+ base-share u1) base-share))
        )
        ;; Store participant and share
        (map-set expense-participants
          {group-id: group-id, expense-id: expense-id, index: index}
          {participant: participant, share: share})

        ;; Update balances if not the payer
        (if (not (is-eq participant paid-by))
          (update-balance group-id participant paid-by share)
          true
        )

        ;; Return updated context with incremented index
        (ok (merge success {current-index: (+ index u1)}))
      )
    error (err error)
  )
)

;; Update balance between debtor and creditor
(define-private (update-balance (group-id uint) (debtor principal) (creditor principal) (amount uint))
  (let
    (
      (balance-key {group-id: group-id, debtor: debtor, creditor: creditor})
      (current-balance (default-to u0 (map-get? balances balance-key)))
      (debtor-key {group-id: group-id, member: debtor})
      (creditor-key {group-id: group-id, member: creditor})
      (debtor-info (unwrap-panic (map-get? members debtor-key)))
      (creditor-info (unwrap-panic (map-get? members creditor-key)))
    )
    ;; Update balance
    (map-set balances balance-key (+ current-balance amount))

    ;; Update member totals
    (map-set members debtor-key (merge debtor-info {
      total-owed: (+ (get total-owed debtor-info) amount)
    }))
    (map-set members creditor-key (merge creditor-info {
      total-owing: (+ (get total-owing creditor-info) amount)
    }))
    true
  )
)

;; Read-only functions

;; Get group info
(define-read-only (get-group (group-id uint))
  (map-get? groups group-id)
)

;; Get member info
(define-read-only (get-member-info (group-id uint) (member principal))
  (map-get? members {group-id: group-id, member: member})
)

;; Get expense
(define-read-only (get-expense (group-id uint) (expense-id uint))
  (map-get? expenses {group-id: group-id, expense-id: expense-id})
)

;; Get expense participant
(define-read-only (get-expense-participant (group-id uint) (expense-id uint) (index uint))
  (map-get? expense-participants {group-id: group-id, expense-id: expense-id, index: index})
)

;; Get balance between debtor and creditor
(define-read-only (get-balance (group-id uint) (debtor principal) (creditor principal))
  (default-to u0 (map-get? balances {group-id: group-id, debtor: debtor, creditor: creditor}))
)

;; Get net balance for a member (what they owe vs what they're owed)
(define-read-only (get-net-balance (group-id uint) (member principal))
  (let
    (
      (member-info (unwrap! (map-get? members {group-id: group-id, member: member}) ERR-MEMBER-NOT-FOUND))
    )
    (ok (- (get total-owing member-info) (get total-owed member-info)))
  )
)

;; Get settlement
(define-read-only (get-settlement (group-id uint) (settlement-id uint))
  (map-get? settlements {group-id: group-id, settlement-id: settlement-id})
)

;; Get group stats
(define-read-only (get-group-stats (group-id uint))
  (let
    (
      (group (unwrap! (map-get? groups group-id) ERR-GROUP-NOT-FOUND))
    )
    (ok {
      name: (get name group),
      creator: (get creator group),
      member-count: (get member-count group),
      total-expenses: (- (get next-expense-id group) u1),
      total-settlements: (- (get next-settlement-id group) u1),
      paused: (get paused group)
    })
  )
)

;; Get member at index
(define-read-only (get-member-at-index (group-id uint) (index uint))
  (map-get? group-member-list {group-id: group-id, index: index})
)

;; Set factory contract (admin function - should be called once)
(define-public (set-factory-contract (factory principal))
  (begin
    (asserts! (is-eq tx-sender (var-get factory-contract)) ERR-UNAUTHORIZED)
    (var-set factory-contract factory)
    (ok true)
  )
)

Functions (23)

FunctionAccessArgs
initialize-grouppublicgroup-id: uint, name: (string-ascii 50
add-memberpublicgroup-id: uint, member: principal, nickname: (string-ascii 32
add-expensepublicgroup-id: uint, description: (string-ascii 200
settle-debt-stxpublicgroup-id: uint, creditor: principal, amount: uint
pause-grouppublicgroup-id: uint
unpause-grouppublicgroup-id: uint
update-expense-descriptionpublicgroup-id: uint, expense-id: uint, new-description: (string-ascii 200
remove-memberpublicgroup-id: uint, member: principal
validate-participantsprivategroup-id: uint, participants: (list 20 principal
validate-participantprivateparticipant: principal, acc: (response uint uint
process-participantsprivategroup-id: uint, expense-id: uint, participants: (list 20 principal
process-single-participantprivateparticipant: principal, acc: (response {group-id: uint, expense-id: uint, base-share: uint, remainder: uint, paid-by: principal, current-index: uint} uint
update-balanceprivategroup-id: uint, debtor: principal, creditor: principal, amount: uint
get-groupread-onlygroup-id: uint
get-member-inforead-onlygroup-id: uint, member: principal
get-expenseread-onlygroup-id: uint, expense-id: uint
get-expense-participantread-onlygroup-id: uint, expense-id: uint, index: uint
get-balanceread-onlygroup-id: uint, debtor: principal, creditor: principal
get-net-balanceread-onlygroup-id: uint, member: principal
get-settlementread-onlygroup-id: uint, settlement-id: uint
get-group-statsread-onlygroup-id: uint
get-member-at-indexread-onlygroup-id: uint, index: uint
set-factory-contractpublicfactory: principal