# Origamy playbook spec v1. Reference: /playbooks/spec-format/
spec_version: 1
id: fitness.membership-renewals
title: Membership renewal reminders
sector: fitness
source:
  kind: synthesized
  notes: >-
    No gym or studio has built this with us yet. Modelled on the base renewal-or-payment
    reminder: an expiry date kept as a trait, a daily schedule over segments for "ending soon",
    "ends today" and "just lapsed", and a call task for people who haven't renewed. The
    monthly summary and the optional WhatsApp versions are new.
derived_from: base.payment-reminder
vocabulary:
  person: member
  people: members
  group: team
summary: >-
  Reminds members a week before their membership ends and on the day, and texts members who
  lapsed in the last two weeks. If they still haven't renewed two days later, the front desk gets a
  task to call. A monthly summary shows renewals due and members lost.
when_to_use:
  - Your members pay for a month, a quarter or a year at a time, and many forget to renew.
  - You already track expiry dates in a spreadsheet, or you record new members on a form.
  - You would rather call a lapsed member than lose them quietly.

requires:
  data_in:
    - how: sheet_sync
      what: "Your members sheet in Google Sheets or Excel, synced as People every hour: phone, first name, expiry date and a status column (active, expired or frozen)."
    - how: staff_form
      what: "Or the New member form from the Trial class to member playbook, which saves the same expiry date and status."
  channels:
    - channel: sms
      for: The three renewal messages
      needs: An SMS provider, or Origamy managed SMS after business verification (KYC).
    - channel: whatsapp
      for: The same three messages, if you switch WhatsApp on
      needs: A connected WhatsApp Business number, and the three templates approved by Meta.
  events: []
  traits:
    - "{{ params.expires_trait }}"
    - "{{ params.status_trait }}"

parameters:
  - key: org_name
    ask: true
    question: What should messages call your gym or studio?
    label: Gym name
    type: string
    example: Northwind Fitness
  - key: front_desk_team
    ask: true
    question: Which team calls members who haven't renewed? (can be one person)
    label: Front desk
    type: team_name
    default: Front desk
  - key: renew_url
    ask: true
    question: Where can members renew online? (a payment or booking link; leave empty to ask them to call or visit)
    label: Renewal link
    type: url
    default: ""
  - key: use_whatsapp
    ask: true
    question: Should reminders go by WhatsApp instead of SMS?
    help: Needs a connected WhatsApp Business number and Meta approval for three templates.
    label: Use WhatsApp
    type: boolean
    default: false
  - key: remind_before
    ask: true
    question: How long before the membership ends should the first reminder go?
    label: First reminder before expiry
    type: duration
    default: 7d
  - key: contact_phone
    label: Number members call to renew
    type: string
    default: "{{ workspace.phone }}"
  - key: expires_trait
    label: Trait that holds the expiry date
    type: trait_key
    default: membership_expires_at
  - key: status_trait
    label: Trait that says active, expired or frozen
    type: trait_key
    default: membership_status
  - key: manager_team
    label: Team that gets the monthly summary
    type: team_name
    default: Managers
  - key: send_time
    label: Daily send time
    type: time
    default: "09:00"
  - key: monthly_report_cron
    label: When the monthly summary arrives
    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
    type: timezone
    default: "{{ workspace.timezone }}"

timings:
  - Runs every day at {{ params.send_time }} in {{ params.timezone }}.
  - A reminder when the membership ends within {{ params.remind_before }}, another on the day it ends, and a message in the 14 days after it ended. Each reminder reaches a member once per membership.
  - Two days after the lapsed message, if the member still hasn't renewed, a call task for {{ params.front_desk_team }}, due in 48 hours.
  - A summary on the 1st of each month.

customer_must:
  - "Keep each member's expiry date in the {{ params.expires_trait }} column as a date (like 2026-12-01) and update it when they renew: a future date is what stops the reminders. Fill {{ params.status_trait }} for every row (active, expired or frozen). Set a member to frozen to pause their reminders."
  - Add the people who make calls to the {{ params.front_desk_team }} team (it can be one person), and managers to {{ params.manager_team }}.
  - "Connect an SMS provider or finish business verification. If you chose WhatsApp, connect your WhatsApp Business number and submit the three templates for Meta approval first."
  - Check the five segment sizes against your sheet, then switch the three follow-ups on.
  - "Keep it friendly: don't mention fees or arrears in the messages, and follow your local consumer rules."

objects:
  - ref: team_front_desk
    kind: team
    about: Calls members who haven't renewed. Can be one person.
    body: { name: "{{ params.front_desk_team }}" }
  - ref: team_managers
    kind: team
    about: Gets the monthly summary.
    body: { name: "{{ params.manager_team }}" }

  - ref: seg_ending_soon
    kind: segment
    about: Membership ends within the first-reminder window.
    body:
      name: Membership ending soon
      description: "{{ params.expires_trait }} within the next {{ params.remind_before }}, not frozen."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: within_next, value: "{{ params.remind_before }}" }
          - { type: trait, trait_key: "{{ params.status_trait }}", cond_operator: neq, value: frozen }
  - ref: seg_ends_today
    kind: segment
    about: Membership ends today.
    body:
      name: Membership ends today
      description: "{{ params.expires_trait }} is today, not frozen."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: within_last, value: 1d }
          - { type: trait, trait_key: "{{ params.status_trait }}", cond_operator: neq, value: frozen }
  - ref: seg_just_lapsed
    kind: segment
    about: Membership ended between 1 and 14 days ago.
    body:
      name: Membership just lapsed
      description: "{{ params.expires_trait }} between 1 and 14 days ago, not frozen."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: older_than, value: 1d }
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: within_last, value: 14d }
          - { type: trait, trait_key: "{{ params.status_trait }}", cond_operator: neq, value: frozen }
  - ref: seg_due_month
    kind: segment
    about: Membership ends in the next 30 days (for the monthly summary).
    body:
      name: Renewals due in the next 30 days
      description: "{{ params.expires_trait }} within the next 30 days, not frozen."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: within_next, value: 30d }
          - { type: trait, trait_key: "{{ params.status_trait }}", cond_operator: neq, value: frozen }
  - ref: seg_lapsed_month
    kind: segment
    about: Membership ended in the last 30 days (for the summary and the call task).
    body:
      name: Lapsed in the last 30 days
      description: "{{ params.expires_trait }} between 1 and 30 days ago, not frozen."
      segment_type: dynamic
      status: active
      definition:
        operator: and
        conditions:
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: older_than, value: 1d }
          - { type: trait, trait_key: "{{ params.expires_trait }}", cond_operator: within_last, value: 30d }
          - { type: trait, trait_key: "{{ params.status_trait }}", cond_operator: neq, value: frozen }

  - ref: tmpl_soon_sms
    kind: template
    about: Reminder a week before the membership ends.
    body:
      name: Membership ending soon (SMS)
      channel: sms
      category: Billing
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "Hi {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, your {{ params.org_name }} membership ends on {{ traits.{{ params.expires_trait }} | date: \"%-d %b\" }}. To renew, call {{ params.contact_phone }} or ask at the front desk{% if \"{{ params.renew_url }}\" != \"\" %}, or use {{ params.renew_url }}{% endif %}. Reply STOP to opt out."
  - ref: tmpl_soon_wa
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Reminder before the end date on WhatsApp (needs Meta approval).
    body:
      name: Membership ending soon (WhatsApp)
      channel: whatsapp
      category: Billing
      style: text
      status: active
      subject: ""
      provider_template_id: gym_renewal_soon
      metadata: { whatsapp: { category: UTILITY, language: en, samples: { "1": there, "2": 8 Nov } } }
      variables:
        "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}"
        "2": "{{ traits.{{ params.expires_trait }} | date: \"%-d %b\" }}"
      body: "Hi {{1}}, your {{ params.org_name }} membership ends on {{2}}. To renew, reply here, call {{ params.contact_phone }} or ask at the front desk."
  - ref: tmpl_today_sms
    kind: template
    about: Reminder on the day the membership ends.
    body:
      name: Membership ends today (SMS)
      channel: sms
      category: Billing
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "Hi {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, your {{ params.org_name }} membership ends today. Renew at the front desk or call {{ params.contact_phone }}{% if \"{{ params.renew_url }}\" != \"\" %}, or use {{ params.renew_url }}{% endif %} so you don't miss a class. Reply STOP to opt out."
  - ref: tmpl_today_wa
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Day-of reminder on WhatsApp (needs Meta approval).
    body:
      name: Membership ends today (WhatsApp)
      channel: whatsapp
      category: Billing
      style: text
      status: active
      subject: ""
      provider_template_id: gym_renewal_today
      metadata: { whatsapp: { category: UTILITY, language: en, samples: { "1": there } } }
      variables:
        "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}"
      body: "Hi {{1}}, your {{ params.org_name }} membership ends today. Reply here to renew, or call {{ params.contact_phone }}, so you don't miss a class."
  - ref: tmpl_lapsed_sms
    kind: template
    about: Kind message after the membership has ended.
    body:
      name: Membership ended (SMS)
      channel: sms
      category: Billing
      style: text
      status: active
      subject: ""
      metadata: {}
      variables: {}
      body: "Hi {{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}, your {{ params.org_name }} membership ended on {{ traits.{{ params.expires_trait }} | date: \"%-d %b\" }}. We'd love to have you back. To renew, call {{ params.contact_phone }}{% if \"{{ params.renew_url }}\" != \"\" %} or use {{ params.renew_url }}{% endif %}. Already renewed? Thank you, and ignore this. Reply STOP to opt out."
  - ref: tmpl_lapsed_wa
    kind: template
    when: { param: use_whatsapp, equals: true }
    about: Message after the membership has ended, on WhatsApp (needs Meta approval).
    body:
      name: Membership ended (WhatsApp)
      channel: whatsapp
      category: Billing
      style: text
      status: active
      subject: ""
      provider_template_id: gym_renewal_lapsed
      metadata: { whatsapp: { category: MARKETING, language: en, samples: { "1": there, "2": 8 Nov } } }
      variables:
        "1": "{{ traits.first_name | default: \"{{ params.greeting_fallback }}\" }}"
        "2": "{{ traits.{{ params.expires_trait }} | date: \"%-d %b\" }}"
      body: "Hi {{1}}, your {{ params.org_name }} membership ended on {{2}}. We'd love to have you back. Reply here to renew, or call {{ params.contact_phone }}. Already renewed? Thank you, and ignore this."

  - ref: wf_soon
    kind: workflow
    about: 'Daily run over "ending soon": one reminder per membership.'
    body:
      name: "Renewal reminder 1: ending soon"
      description: Every day, a reminder to members whose membership ends soon.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_ending_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_wa.id }}" } } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 720
      goal: {}
      exit_conditions: []
  - ref: wf_today
    kind: workflow
    about: 'Daily run over "ends today": one reminder.'
    body:
      name: "Renewal reminder 2: ends today"
      description: Every day, a reminder to members whose membership ends today.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_ends_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_wa.id }}" } } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 720
      goal: {}
      exit_conditions: []
  - ref: wf_lapsed
    kind: workflow
    about: 'Daily run over "just lapsed": a message, then a front-desk call if they still haven''t renewed.'
    body:
      name: "Renewal reminder 3: lapsed"
      description: Every day, a message to members who just lapsed, then a call task if they still haven't renewed. The outcome is saved.
      enabled: false
      trigger_type: schedule
      trigger_config: { segment_id: "{{ ref.seg_just_lapsed.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_lapsed_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_lapsed_wa.id }}" } } } }
        - { id: s3, type: delay, config: { duration: 2d } }
        - { id: s4, type: filter, config: { condition: { type: segment, cond_operator: member_of, value: "{{ ref.seg_lapsed_month.id }}" } } }
        - id: s5
          type: human_task
          config:
            human_task:
              title: "Renewal call: {{ traits.first_name | default: \"a member\" }}"
              instructions: |-
                {{ traits.first_name | default: "This member" }} ({{ traits.phone }}) let their membership end on {{ traits.{{ params.expires_trait }} | date: "%-d %b" }} and hasn't renewed after a message.
                Call to say you've missed them, ask if anything got in the way and offer to renew them today. Note what they say.
              assignee_refs: [{ type: team, id: "{{ ref.team_front_desk.id }}" }]
              strategy: round_robin
              completion_policy: first_response_wins
              outcome_options:
                - { id: renewing, label: Renewing, intent: positive }
                - { id: not_renewing, label: Not renewing, intent: neutral }
                - { id: not_reached, label: Could not reach, intent: negative }
              due_hours: 48
              wait: true
              wait_timeout_hours: 168
              notify: immediate
        - { id: s6, type: action, config: { action_type: set_trait, traits: { renewal_call: "{{ properties.task_outcome | default: \"unknown\" }}", renewal_note: "{{ properties.task_note }}" } } }
      re_entry_policy: cooldown
      re_entry_cooldown_hours: 720
      goal: {}
      exit_conditions: []

  - ref: report_monthly
    kind: report
    about: Renewals due in the next 30 days and members who lapsed in the last 30, sent to the managers each month.
    body:
      name: "Monthly: renewals due and lapsed members"
      description: How many memberships end in the next 30 days, and how many ended in the last 30.
      schedule_cron: "{{ params.monthly_report_cron }}"
      timezone: "{{ params.timezone }}"
      enabled: false
      spec:
        period: previous_month
        compare: none
        week_start: monday
        sections:
          - id: m_sizes
            kind: segment_sizes
            title: Renewals due next, and members lapsed
            params:
              segment_ids: ["{{ ref.seg_due_month.id }}", "{{ ref.seg_lapsed_month.id }}"]
          - { id: m_tasks, kind: tasks_summary, title: Renewal calls, params: {} }
      recipients: [{ type: team, id: "{{ ref.team_managers.id }}" }]
      channel_policy: { mode: email_only }

eval:
  name: 82_fitness_membership_renewals
  mode: task
  vertical: ""
  prompt: >-
    We're a gym and keep our members in a Google Sheet with their phone, expiry date and
    whether they're active, expired or frozen. Text members a week before their membership
    ends and again on the day. If it ended in the last two weeks, text them again, and if they
    still haven't renewed two days later, give the front desk a task to call. Skip frozen
    members. Send our managers a monthly summary of renewals due and members lost.
  expect_artifacts: [segment, workflow, message_template, report]
  forbid_terms: [discount]
