Membership Events

The memberships Lambda consumes the site-user-service-users Kinesis stream and translates membership lifecycle events from the user-service into subscription record updates in DynamoDB. It is the bridge between a user’s membership state (managed by the user-service) and the subscription billing records (owned by this service).

Overview

When a user’s membership changes — they cancel, pause, resume, or close their account — the user-service publishes an event to its Kinesis stream. The memberships Lambda receives those events, and most handlers acquire a distributed lock on the user’s subscription records before writing updated subscription rows to the billing-activity table. The exception is CLOSEACCOUNT, which skips the lock as it is an unconditional terminal operation.

The Lambda does not perform any billing or collection. Its sole responsibility is to keep subscription record statuses consistent with the membership state as reported by the user-service.

Dependencies

Dependency Implementation Role

subscriptions.Repository

pkg/dynamo/subscriptions.DDBRepository

Read and batch-write subscription records in billing-activity / billing-activity-history

fmsdkusers.API

pkg/users.Client wrapping FMSDK user client

Look up the user’s current status field before processing; events for inactive users are discarded

dynamo.Locker

pkg/dynamo.FloatMeLock (DynamoDB-backed)

Acquire a per-user distributed lock before mutating subscription records to prevent concurrent writes from other Lambdas

The memberships Lambda does not interact with pkg/underwriting. The pkg/underwriting package is used only by other Lambdas (e.g. api). The membership handler’s only external service call is to the user-service to verify user status.

Kinesis Event Filtering

The Lambda is triggered by the site-user-service-users Kinesis stream. A Kinesis filter applied at the event source mapping level ensures only membership-related events reach the Lambda. The filter pattern (from deploy/lambda.tf) is:

{
  "data": {
    "type": [
      "UPGRADE",
      "DOWNGRADE",
      "CANCEL",
      "RETRACT",
      "AUTODOWNGRADED",
      "GONETOCOLLECTIONS",
      "PAYNOW",
      "SUB_PAUSED",
      "UNPAUSE",
      "UNPAUSE_CHARGE",
      "CLOSEACCOUNT",
      "REACTIVATE"
    ]
  }
}

Events that pass the Kinesis filter are decoded into a Membership struct and dispatched through ProcessMembership. Not all filtered event types have a handler in the current code. Types that reach the default case in the switch statement are returned as ErrUnsupportedType and cause the Kinesis record to be reported as a failure (the batch is bisected on function error).

Handled vs. Filtered Event Types

Event Type Handler Description

CANCEL

CancelMembership

Membership cancellation — transitions SCHEDULED/PAUSED subscriptions to PENDING_CANCELLATION

SUB_PAUSED

PauseMembership

Membership pause — transitions SCHEDULED subscriptions to PAUSED

RETRACT

RetractMembershipChangeEvent

Retracts a pending membership change — clears the updated_event field on SCHEDULED subscriptions

UNPAUSE

UnpauseMembership

Membership resume (end of pause period, no immediate charge) — transitions the soonest PAUSED subscription to SCHEDULED; cancels remaining PAUSED records

UNPAUSE_CHARGE

UnpauseMembership

Membership resume with immediate charge — same as UNPAUSE but marks the soonest PAUSED subscription with pause-pending-resume so the collections engine charges it

CLOSEACCOUNT

HandleCloseAccount

Account closure — transitions SCHEDULED/PAUSED subscriptions to CANCELLED unconditionally (no user-status check)

UPGRADE

— (no handler, ErrUnsupportedType)

Upgrade events are filtered into the Lambda but handled upstream by the API layer; the membership Lambda returns an error for these

DOWNGRADE

— (no handler, ErrUnsupportedType)

Same as UPGRADE

AUTODOWNGRADED

— (no handler, ErrUnsupportedType)

Automatic downgrade signal; not handled in the membership processor

GONETOCOLLECTIONS

— (no handler, ErrUnsupportedType)

Informational event; no subscription mutation needed

PAYNOW

— (no handler, ErrUnsupportedType)

Informational event; no subscription mutation needed

REACTIVATE

— (no handler, ErrUnsupportedType)

Reactivation is handled by the API layer, not the membership processor

Event Processing Flows

Each event type follows a common preamble before its type-specific logic.

Common Preamble (CANCEL, SUB_PAUSED, RETRACT, UNPAUSE, UNPAUSE_CHARGE)

Kinesis record received
        │
        ▼
Unmarshal JSON → fmevents.Event
        │
        ├── Unmarshal error ──► log error, add sequence number to failures, continue
        │
        ▼
Decode event payload → Membership struct
  (user_id, tier, term, confirmation_id, subscription_id,
   start_date, status, pause_duration_months)
        │
        ├── Decode error ──► log error, add sequence number to failures, continue
        │
        ▼
Set Membership.EventType = event.Type
        │
        ▼
ProcessMembership (switch on EventType)
CLOSEACCOUNT skips the user-status check entirely and has no lock acquisition in the current implementation — it queries subscriptions directly.

CANCEL

CANCEL received
        │
        ▼
GetUser via User Service
        │
        ├── Not found ──► log warning, no-op (return nil)
        ├── Error ──────► return error (record reported as failure)
        │
        ▼
Is user.Status == "ACTIVE"?
        │
        ├── No ──► log warning, discard event (return nil)
        │
        ▼
Acquire DynamoDB lock (LockSubscriptionForBilling, user_id)
        │
        ├── Error ──► return error
        │
        ▼
ListByUserID (billing-activity table, all records for user)
        │
        ▼
Filter: keep only SCHEDULED and PAUSED subscriptions
        │
        ├── None found ──► log warning, return nil (no-op)
        │
        ▼
GenerateSubscriptionUpdates:
  updated_event ──► "PENDING_CANCELLATION"
  last_run_date ──► now (UTC)
  (SubscriptionStatus is NOT changed — status stays SCHEDULED/PAUSED)
        │
        ▼
SubRepo.AddAll (batch-write to billing-activity + billing-activity-history)
        │
        ▼
Emit Datadog metric: memberships.user.cancelled
        │
        ▼
Release lock (deferred)
The CANCEL handler marks subscriptions with updated_event = "PENDING_CANCELLATION" but does not change billing_status. The subscription status is updated to CANCELLED by the collections engine when the billing cycle runs and observes the updated_event flag.

SUB_PAUSED

SUB_PAUSED received
        │
        ▼
GetUser via User Service
        │
        ├── Not found ──► log warning, no-op
        ├── Error ──────► return error
        │
        ▼
Is user.Status == "ACTIVE"?
        │
        ├── No ──► log warning, discard event (return nil)
        │
        ▼
Acquire DynamoDB lock
        │
        ▼
ListByUserID
        │
        ▼
Filter: keep only SCHEDULED subscriptions
        │
        ├── None found ──► log warning, return nil (no-op)
        │
        ▼
GenerateSubscriptionUpdates:
  billing_status       ──► PAUSED
  updated_event        ──► "SUB_PAUSED"
  pause_duration_months ──► membership.PauseDurationMonths
                             (set to -1 if PauseDurationMonths <= 0,
                              meaning indefinite pause)
  term                 ──► "MONTHLY"  (forced regardless of prior term)
  last_run_date        ──► now (UTC)
        │
        ▼
SubRepo.AddAll
        │
        ▼
Release lock (deferred)
When pausing, the term is forced to MONTHLY on all affected subscription records.

RETRACT

RETRACT received
        │
        ▼
GetUser via User Service
        │
        ├── Not found ──► log warning, no-op
        ├── Error ──────► return error
        │
        ▼
Is user.Status == "ACTIVE"?
        │
        ├── No ──► log warning, discard event (return nil)
        │
        ▼
Acquire DynamoDB lock
        │
        ▼
ListByUserID (all records, not pre-filtered)
        │
        ▼
For each subscription:
  Is billing_status == SCHEDULED AND updated_event != ""?
        │
        ├── Yes ──► clear updated_event ──► ""
        │           set term            ──► retract.Term
        │           set last_run_date   ──► now (UTC)
        │           add to updateSubs list
        │
        └── No  ──► skip
        │
        ▼
len(updateSubs) > 0?
        │
        ├── No  ──► log warning "user has no events to retract"
        │
        ├── Yes ──► SubRepo.AddAll(updateSubs)
        │           log info "Successfully retracted pending event!"
        │
        ▼
Release lock (deferred)
RETRACT undoes a pending mutation on a SCHEDULED subscription by clearing its updated_event field. This effectively cancels a queued CANCEL or PAUSE operation before the collections engine has processed it.

UNPAUSE

UNPAUSE received
        │
        ▼
GetUser via User Service
        │
        ├── Not found ──► log warning, no-op
        ├── Error ──────► return error
        │
        ▼
Is user.Status == "ACTIVE"?
        │
        ├── No ──► log warning, discard event (return nil)
        │
        ▼
Acquire DynamoDB lock
        │
        ▼
ListByUserID
        │
        ▼
Filter: keep only PAUSED subscriptions
        │
        ├── None found ──► log warning, return nil (no-op)
        │
        ▼
Sort PAUSED subscriptions by SubscriptionDate descending
  (most recent first — index 0 is the soonest upcoming)
        │
        ▼
For index 0 (soonest PAUSED sub):
  billing_status       ──► SCHEDULED
  pause_duration_months ──► 0  (cleared)
  updated_event        ──► "UNPAUSE"
  last_run_date        ──► now (UTC)
        │
For all other PAUSED subs (index 1+):
  billing_status       ──► CANCELLED
  updated_event        ──► "UNPAUSE"
  last_run_date        ──► now (UTC)
        │
        ▼
SubRepo.AddAll
        │
        ▼
Release lock (deferred)

UNPAUSE_CHARGE

Follows the same flow as UNPAUSE with one difference for the soonest PAUSED subscription:

For index 0 (soonest PAUSED sub):
  billing_status       ──► SCHEDULED
  pause_duration_months ──► 0
  updated_event        ──► "pause-pending-resume"   ← differs from UNPAUSE
  last_run_date        ──► now (UTC)

The pause-pending-resume updated_event signals the collections engine (pause collection path) to attempt an immediate collection for this subscription when the pause collections job next runs, rather than waiting for the regular scheduled billing date.

CLOSEACCOUNT

CLOSEACCOUNT received
        │
        ▼
(no user-status check, no lock acquisition)
        │
        ▼
ListByUserID (billing-activity, all records for user)
        │
        ▼
Filter: keep only SCHEDULED and PAUSED subscriptions
        │
        ├── None found ──► log warning, return nil (no-op)
        │
        ▼
For each matching subscription:
  billing_status ──► CANCELLED
  updated_event  ──► "account-closed"
  last_run_date  ──► now (UTC)
        │
        ▼
SubRepo.AddAll (batch-write to billing-activity + billing-activity-history)
        │
        ▼
log info "Successfully processed close account event!"
CLOSEACCOUNT is the only handler that skips both the user-status guard and the distributed lock. It sets billing_status directly to CANCELLED — unlike CANCEL which only sets updated_event.

State-Transition Diagram

The diagram below shows how membership events affect the billing_status field of subscription records. Only the transitions driven by the memberships Lambda are shown; transitions driven by the collections engine or ACH callbacks are covered in Subscription Lifecycle and ACH Processing.

                   ┌────────────────────────────────────────────────────┐
                   │              billing_status transitions             │
                   │            (memberships Lambda only)               │
                   └────────────────────────────────────────────────────┘

   User-service event          billing_status before    billing_status after
   ─────────────────           ─────────────────────    ────────────────────

   CANCEL           ──────────► SCHEDULED              ──► SCHEDULED *
                    ──────────► PAUSED                 ──► PAUSED *
                                (* updated_event set to "PENDING_CANCELLATION";
                                   status unchanged, awaiting collections run)

   SUB_PAUSED       ──────────► SCHEDULED              ──► PAUSED

   RETRACT          ──────────► SCHEDULED               ──► SCHEDULED
                                (clears updated_event; reverses a prior CANCEL
                                 or SUB_PAUSED that hasn't been collected yet)

   UNPAUSE          ──────────► PAUSED (soonest)        ──► SCHEDULED
                    ──────────► PAUSED (all others)     ──► CANCELLED

   UNPAUSE_CHARGE   ──────────► PAUSED (soonest)        ──► SCHEDULED
                                (updated_event = "pause-pending-resume")
                    ──────────► PAUSED (all others)     ──► CANCELLED

   CLOSEACCOUNT     ──────────► SCHEDULED              ──► CANCELLED
                    ──────────► PAUSED                 ──► CANCELLED

Full state machine including collections and ACH callbacks:

   SCHEDULED ──► ACHSENT ──► COMPLETED
                          └──► ERROR ──► (retry cycle) ──► ACHSENT
                                     └──► WAIVED
             └──► (SUB_PAUSED)    ──► PAUSED
             └──► (CANCEL)        ──► updated_event = PENDING_CANCELLATION
                                       │
                                       └──► CANCELLED  (by collections run)
             └──► (CLOSEACCOUNT)  ──► CANCELLED

   PAUSED ──► (UNPAUSE / UNPAUSE_CHARGE) ──► SCHEDULED (soonest)
          └──► (UNPAUSE / UNPAUSE_CHARGE) ──► CANCELLED (extras)
          └──► (CLOSEACCOUNT)            ──► CANCELLED
          └──► (collections pause job)   ──► PAUSED_SKIPPED (billing skipped)

Package Structure

pkg/memberships/ contains the following files:

File Contents

memberships.go

Membership struct definition. AdjustSubscriptionDate helper (adds months to a given time at 06:00 UTC).

processor.go

Processor struct, NewProcessor constructor, Kinesis entry-point method (decodes Kinesis records and iterates), ProcessMembership dispatch switch, isUserActive helper.

cancel.go

CancelMembership — marks SCHEDULED/PAUSED subscriptions with PENDING_CANCELLATION updated_event.

pause.go

PauseMembership — transitions SCHEDULED subscriptions to PAUSED status.

unpause.go

UnpauseMembership and unpauseUser — restores the soonest PAUSED subscription to SCHEDULED; cancels excess PAUSED records.

retract.go

RetractMembershipChangeEvent — clears the updated_event field on SCHEDULED subscriptions to undo a pending change.

closeaccount.go

HandleCloseAccount — unconditionally cancels all SCHEDULED/PAUSED subscriptions on account closure.

Processor Struct

type Processor struct {
    SubRepo     subscriptions.Repository
    UserService fmsdkusers.API
    DynamoLock  dynamo.Locker
    CurrentTime time.Time
}

CurrentTime is injected at Lambda cold-start (time.Now().UTC() in main.go) and is available for date calculations, though individual handlers call time.Now().UTC() directly for last_run_date stamps.

Distributed Locking

Most handlers acquire a per-user DynamoDB lock via DynamoLock.LockSubscriptionForBilling(ctx, userID) before reading or writing subscription records. The lock is always released via a deferred call to DynamoLock.ReleaseSubscription. This prevents concurrent writes from the collections-worker, ach-handler, or api Lambda from racing with the membership event processor.

The exception is HandleCloseAccount, which does not acquire the lock. This is intentional: account closure is a terminal, unconditional operation that does not need to coordinate with in-progress collection attempts.

Infrastructure

The memberships Lambda is configured in deploy/lambda.tf as follows:

  • Trigger: Kinesis stream site-user-service-users with the twelve-type event filter above

  • Batch size: controlled by var.kinesis_stream_batch_size

  • Starting position: LATEST

  • Bisect on error: enabled — a failing batch is split to isolate bad records

  • Timeout: 840 seconds

  • DynamoDB access: billing-activity (GetItem, PutItem, UpdateItem, Query, BatchWriteItem), billing-activity-history (same), and the locks table (GetItem, PutItem, UpdateItem, Query, DeleteItem)

  • User Service access: execute-api:Invoke on the site-user-service API Gateway

  • Environment variables: MEMBERSHIPS_DYNAMO_REGION, DYNAMO_LOCK_REGION, USER_SERVICE_URL, USER_SERVICE_REGION

No Secrets Manager secrets are loaded by this Lambda.

Error Handling

The Kinesis method collects the sequence numbers of all failed records and returns them via kinesis.Response(kinesisErrs…​). This enables partial batch failure reporting: records that processed successfully are not retried, while failed records are retried by the Kinesis consumer.

Unhandled event types (those in the Kinesis filter but without a ProcessMembership case) return ErrUnsupportedType, which causes the record to be reported as a failure and retried. This is a known rough edge for the event types listed as "no handler" in the table above (UPGRADE, DOWNGRADE, AUTODOWNGRADED, GONETOCOLLECTIONS, PAYNOW, REACTIVATE). These events appear in the filter pattern for historical reasons and should either be removed from the filter or given explicit no-op handlers.

  • Subscription Lifecycle — Full billing_status state machine and all transition sources

  • Collections Engine — How the collections engine acts on updated_event flags set by this Lambda

  • ACH Processing — The ach-handler Lambda that handles post-collection status updates

  • DynamoDB Tablesbilling-activity table schema, GSIs, and access patterns

  • Event Flows — Kinesis stream topology and all consumed event sources

  • Architecture — Lambda function inventory and system context