# Origamy playbook spec v1. Reference: /playbooks/spec-format/
spec_version: 1
id: lending.cooperative-contributions
title: Cooperative contributions
sector: lending
source:
  kind: synthesized
  notes: >-
    No cooperative has built this with us yet. Written from the base payment reminder and the
    lending repayment reminders (a member list in a synced sheet, a date segment, a daily run),
    with a friendlier, communal tone and a call task for the treasurer instead of a collections
    team. Modelled on how rotating savings and thrift groups usually run: a ledger kept by one
    person, paid weekly or monthly.
derived_from: lending.repayment-reminders
vocabulary:
  person: member
  people: members
  group: executive
summary: >-
  Keeps contributions on time without anyone chasing by hand. Members get a friendly reminder a
  couple of days before their contribution is due and another on the day. If it's missed, they
  get a kind message and the treasurer gets a task to call. The executive gets a monthly report
  of how many members are due, paid and missed.
when_to_use:
  - You run a cooperative, a thrift or rotating savings group (ajo, esusu, chama), or a staff savings scheme.
  - Your treasurer keeps the ledger in a spreadsheet with a due date and a paid or due status for each member.
  - Members pay weekly or monthly by bank transfer, POS or cash at a meeting, and the treasurer spends too long reminding people.

requires:
  data_in:
    - how: sheet_sync
      what: "Your members ledger in Google Sheets or Excel, synced as People (every 15 minutes to daily): phone, first_name, group_name, contribution_due_date and contribution_status (paid or due)."
    - how: csv_import
      what: "Or a one-off CSV of your member list to start with, kept up to date by hand."
  channels:
    - channel: sms
      for: The reminders and the missed-contribution message
      needs: An SMS provider, or Origamy managed SMS after business verification (KYC).
    - channel: whatsapp
      for: The same messages (optional)
      needs: A connected WhatsApp Business number, and Meta approval of the three templates this playbook creates.
    - channel: email
      for: The monthly report to your executive
      needs: Nothing extra. Reports go to your team members by email.
  events: []
  traits:
    - "{{ params.due_trait }}"
    - contribution_status
    - group_name

parameters:
  - key: org_name
    ask: true
    question: What should messages call your cooperative or group?
    label: Group name
    type: string
    example: Northwind Thrift Society
  - key: how_to_pay
    ask: true
    question: How should members pay? (the sentence members will read in every reminder)
    label: How to pay
    type: text
    example: "Pay by bank transfer to the group account, or give it to the treasurer at the next meeting."
  - key: treasurer_team
    ask: true
    question: Which team calls members who miss a contribution? (can be one person, the treasurer)
    label: Treasurer
    type: team_name
    default: Treasurer
  - key: executive_team
    ask: true
    question: Who gets the monthly report? (for example the executive committee)
    label: Executive
    type: team_name
    default: Executive committee
  - key: use_whatsapp
    ask: true
    question: Should reminders go by WhatsApp instead of SMS? (needs a connected WhatsApp Business number)
    label: Use WhatsApp
    type: boolean
    default: false
  - key: due_trait
    label: Trait that holds the contribution due date
    type: trait_key
    default: contribution_due_date
  - key: remind_days
    label: Days before the due date for the first reminder
    type: days
    default: 2
  - key: missed_days
    label: Days after the due date when a missed contribution is still chased
    type: days
    default: 14
  - key: call_due_hours
    label: Hours the treasurer has to make the call
    type: hours
    default: 48
  - key: send_time
    label: Daily send time
    type: time
    default: "09:00"
  - key: report_cron
    label: Monthly report (first day of the month)
    type: cron
    default: "0 8 1 * *"
  - key: greeting_fallback
    label: Greeting when a first name is missing
    type: string
    default: there
  - key: timezone
    label: Time zone for the daily run and the report
    type: timezone
    default: "{{ workspace.timezone }}"

timings:
  - Every day at {{ params.send_time }} ({{ params.timezone }}).
  - A friendly reminder when the contribution is due within {{ params.remind_days }} days, and another on the due day. Members marked paid get neither.
  - If a contribution is still unpaid after the due date, a kind message goes out, and a day later the {{ params.treasurer_team }} team gets a call task due in {{ params.call_due_hours }} hours. A member is chased at most once a week, for {{ params.missed_days }} days after the due date.
  - "Monthly report to {{ params.executive_team }}: {{ params.report_cron }} in {{ params.timezone }}."

customer_must:
  - "Keep one row for each member in your sheet with their phone number, first name, group name, the due date ({{ params.due_trait }}, as 2026-11-30) and contribution_status (paid or due). Update the status when a member pays, and set the next due date and status due when the new cycle starts."
  - Add the treasurer to the {{ params.treasurer_team }} team and your executive to {{ params.executive_team }} (Settings → Collaborators → Teams). A task or report sent to an empty team reaches nobody.
  - Tell members, at a meeting or in your group chat, that reminders will come by SMS or WhatsApp, and that they can reply STOP at any time. Messages never show amounts, so you stay in control of what is said about money.
  - Keep reminders friendly. Never name or shame a member, and never contact a member's family, employer or other members about what they owe. The treasurer calls the member only.
  - "If you chose WhatsApp: connect your WhatsApp Business number and submit the three reminder templates for Meta approval before switching the follow-ups on."
  - Connect an SMS provider or finish business verification, switch the follow-ups and report on, and test with your own phone and a test row.

objects:
  - ref: team_treasurer
    kind: team
    about: Calls members who miss a contribution.
    body: { name: "{{ params.treasurer_team }}" }
  - ref: team_executive
    kind: team
    about: Gets the monthly report.
    body: { name: "{{ params.executive_team }}" }

  - ref: seg_due_soon
    kind: segment
    about: Contribution due within the reminder window, not paid.
    body:
      name: Contribution due soon
      description: "{{ params.due_trait }} within {{ params.remind_days }} days and contribution_status not paid."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.due_trait }}", cond_operator: within_next, value: "{{ params.remind_days }}d" }
          - { type: trait, trait_key: contribution_status, cond_operator: neq, value: paid }
  - ref: seg_due_today
    kind: segment
    about: Contribution due today, not paid.
    body:
      name: Contribution due today
      description: "{{ params.due_trait }} today and contribution_status not paid."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.due_trait }}", cond_operator: within_last, value: 1d }
          - { type: trait, trait_key: contribution_status, cond_operator: neq, value: paid }
  - ref: seg_missed
    kind: segment
    about: Contribution missed within the chase window, not paid.
    body:
      name: Contribution missed
      description: "{{ params.due_trait }} in the last {{ params.missed_days }} days and contribution_status not paid."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.due_trait }}", cond_operator: older_than, value: 1d }
          - { type: trait, trait_key: "{{ params.due_trait }}", cond_operator: within_last, value: "{{ params.missed_days }}d" }
          - { type: trait, trait_key: contribution_status, cond_operator: neq, value: paid }
  - ref: seg_paid
    kind: segment
    about: Members marked as paid.
    body:
      name: Contribution paid
      description: contribution_status is paid.
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: contribution_status, cond_operator: eq, value: paid }

  - ref: tmpl_soon_sms
    kind: template
    about: Friendly reminder by SMS a couple of days before.
    body:
      name: Contribution coming up (SMS)
      channel: sms
      category: Contributions
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "{{ params.org_name }}: hello {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, a friendly reminder that your {{ traits.group_name | default: \"{{ params.org_name }}\" }} contribution is coming up on {{ traits.{{ params.due_trait }} | date: \"%a %-d %b\" }}. {{ params.how_to_pay }} Thank you for keeping the group strong. Reply STOP to opt out."
  - ref: tmpl_today_sms
    kind: template
    about: Reminder by SMS on the due day.
    body:
      name: Contribution due today (SMS)
      channel: sms
      category: Contributions
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "{{ params.org_name }}: hello {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, your {{ traits.group_name | default: \"{{ params.org_name }}\" }} contribution is due today. {{ params.how_to_pay }} If you've already paid, thank you. Reply STOP to opt out."
  - ref: tmpl_missed_sms
    kind: template
    about: Kind message by SMS when a contribution is missed.
    body:
      name: Contribution missed (SMS)
      channel: sms
      category: Contributions
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "{{ params.org_name }}: hello {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, we haven't recorded your {{ traits.group_name | default: \"{{ params.org_name }}\" }} contribution yet. These things happen. {{ params.how_to_pay }} If you've already paid or need a little time, please tell the treasurer. Reply STOP to opt out."
  - ref: tmpl_soon_whatsapp
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Friendly reminder by WhatsApp. Needs Meta approval before it can be sent.
    body:
      name: Contribution coming up (WhatsApp)
      channel: whatsapp
      category: Contributions
      style: text
      status: active
      subject: ""
      provider_template_id: contribution_coming_up
      metadata: { whatsapp: { category: UTILITY, language: en } }
      variables: { "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}", "2": "{{ traits.group_name | default: \"{{ params.org_name }}\" }}", "3": "{{ traits.{{ params.due_trait }} | date: \"%a %-d %b\" }}" }
      body: "Hello {{1}}, a friendly reminder that your {{2}} contribution is coming up on {{3}}. {{ params.how_to_pay }} Thank you for keeping the group strong."
  - ref: tmpl_today_whatsapp
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Reminder by WhatsApp on the due day. Needs Meta approval before it can be sent.
    body:
      name: Contribution due today (WhatsApp)
      channel: whatsapp
      category: Contributions
      style: text
      status: active
      subject: ""
      provider_template_id: contribution_due_today
      metadata: { whatsapp: { category: UTILITY, language: en } }
      variables: { "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}", "2": "{{ traits.group_name | default: \"{{ params.org_name }}\" }}" }
      body: "Hello {{1}}, your {{2}} contribution is due today. {{ params.how_to_pay }} If you've already paid, thank you."
  - ref: tmpl_missed_whatsapp
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Kind message by WhatsApp when a contribution is missed. Needs Meta approval before it can be sent.
    body:
      name: Contribution missed (WhatsApp)
      channel: whatsapp
      category: Contributions
      style: text
      status: active
      subject: ""
      provider_template_id: contribution_missed
      metadata: { whatsapp: { category: UTILITY, language: en } }
      variables: { "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}", "2": "{{ traits.group_name | default: \"{{ params.org_name }}\" }}" }
      body: "Hello {{1}}, we haven't recorded your {{2}} contribution yet. These things happen. {{ params.how_to_pay }} If you've already paid or need a little time, please tell the treasurer."

  - ref: report_monthly
    kind: report
    about: Each month, how many members are due, paid and missed, to the executive.
    body:
      name: "Monthly: contributions"
      description: How many members are due soon, have paid and have missed a contribution, and how the treasurer's calls went.
      schedule_cron: "{{ params.report_cron }}"
      timezone: "{{ params.timezone }}"
      enabled: false
      spec:
        period: previous_month
        compare: previous_period
        week_start: monday
        sections:
          - id: members
            kind: segment_sizes
            title: Members due, paid and missed
            params: { segment_ids: ["{{ ref.seg_due_soon.id }}", "{{ ref.seg_paid.id }}", "{{ ref.seg_missed.id }}"] }
          - { id: tasks, kind: tasks_summary, title: Treasurer calls, params: {} }
          - { id: note, kind: text, params: { body: "These are the numbers as the ledger stands today. Members marked paid stay counted until the next cycle starts." } }
      recipients: [{ type: team, id: "{{ ref.team_executive.id }}" }]
      channel_policy: { mode: email_only }

  - ref: wf_soon
    kind: workflow
    about: Daily run over "due soon"; one friendly reminder per cycle, by SMS or WhatsApp.
    body:
      name: "Contribution 1: coming up"
      description: Every day, a friendly reminder to members whose contribution is due within the reminder window.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_due_soon.id }}", rrule: FREQ=DAILY, starts_at: "{{ workspace.tomorrow }}T{{ params.send_time }}", timezone: "{{ params.timezone }}" }
      steps:
        - { id: s1, type: action, when: { param: use_whatsapp, equals: false }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_soon_sms.id }}" } } } }
        - { id: s2, type: action, when: { param: use_whatsapp, equals: true }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: whatsapp, template_id: "{{ ref.tmpl_soon_whatsapp.id }}" } } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 96
      goal: {}
      exit_conditions: []
  - ref: wf_today
    kind: workflow
    about: Daily run over "due today"; one reminder, by SMS or WhatsApp.
    body:
      name: "Contribution 2: due today"
      description: Every day, a reminder to members whose contribution is due today.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_due_today.id }}", rrule: FREQ=DAILY, starts_at: "{{ workspace.tomorrow }}T{{ params.send_time }}", timezone: "{{ params.timezone }}" }
      steps:
        - { id: s1, type: action, when: { param: use_whatsapp, equals: false }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_today_sms.id }}" } } } }
        - { id: s2, type: action, when: { param: use_whatsapp, equals: true }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: whatsapp, template_id: "{{ ref.tmpl_today_whatsapp.id }}" } } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 48
      goal: {}
      exit_conditions: []
  - ref: wf_missed
    kind: workflow
    about: 'Daily run over "missed": a kind message, then a call task for the treasurer with the outcome saved.'
    body:
      name: "Contribution 3: missed"
      description: A kind message to members who missed a contribution, then a call task for the treasurer; the outcome is saved on the member.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_missed.id }}", rrule: FREQ=DAILY, starts_at: "{{ workspace.tomorrow }}T{{ params.send_time }}", timezone: "{{ params.timezone }}" }
      steps:
        - { id: s1, type: action, when: { param: use_whatsapp, equals: false }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: sms, template_id: "{{ ref.tmpl_missed_sms.id }}" } } } }
        - { id: s2, type: action, when: { param: use_whatsapp, equals: true }, config: { action_type: send_message, channel_config: { mode: single, single: { channel: whatsapp, template_id: "{{ ref.tmpl_missed_whatsapp.id }}" } } } }
        - { id: s3, type: delay, config: { duration: 1d } }
        - id: s4
          type: human_task
          config:
            human_task:
              title: "Check in: {{ traits.first_name }} {{ traits.last_name }}"
              instructions: |-
                {{ traits.first_name }} {{ traits.last_name }} ({{ traits.phone }}) has an unpaid contribution to {{ traits.group_name | default: "the group" }}.

                Check the ledger first: if they have paid, update the sheet and close this task. Otherwise call the member only, kindly, find out if they need more time, and agree a date. Never speak to their family, employer or other members about it. Choose an outcome and add a short note.
              assignee_refs: [{ type: team, id: "{{ ref.team_treasurer.id }}" }]
              strategy: round_robin
              completion_policy: first_response_wins
              outcome_options:
                - { id: will_pay, label: Promised a date, intent: positive }
                - { id: paid, label: Already paid, intent: positive }
                - { id: needs_time, label: Needs more time, intent: neutral }
                - { id: not_reached, label: Could not reach, intent: negative }
              due_hours: "{{ params.call_due_hours }}"
              wait: true
              wait_timeout_hours: 168
              notify: immediate
        - { id: s5, type: action, config: { action_type: set_trait, traits: { contribution_call: "{{ properties.task_outcome | default: \"unknown\" }}", contribution_call_note: "{{ properties.task_note }}" } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 168
      goal: {}
      exit_conditions: []

eval:
  name: 97_lending_cooperative_contributions
  mode: task
  vertical: ""
  prompt: >-
    We run a thrift cooperative. Our members sheet has each member's phone, group name, the
    date their contribution is due and whether they've paid. Send a friendly SMS two days
    before the due date and on the day. If a member misses it, text them kindly and give our
    treasurer a task to call. Send our executive a monthly report with how many members are
    due, paid and missed. Keep messages free of amounts.
  expect_artifacts: [segment, workflow, message_template, report]
  forbid_terms: []
