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 |
|---|---|---|
|
|
Read and batch-write subscription records in |
|
|
Look up the user’s current |
|
|
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 |
|---|---|---|
|
|
Membership cancellation — transitions SCHEDULED/PAUSED subscriptions to |
|
|
Membership pause — transitions SCHEDULED subscriptions to |
|
|
Retracts a pending membership change — clears the |
|
|
Membership resume (end of pause period, no immediate charge) — transitions the soonest PAUSED subscription to SCHEDULED; cancels remaining PAUSED records |
|
|
Membership resume with immediate charge — same as |
|
|
Account closure — transitions SCHEDULED/PAUSED subscriptions to |
|
— (no handler, |
Upgrade events are filtered into the Lambda but handled upstream by the API layer; the membership Lambda returns an error for these |
|
— (no handler, |
Same as UPGRADE |
|
— (no handler, |
Automatic downgrade signal; not handled in the membership processor |
|
— (no handler, |
Informational event; no subscription mutation needed |
|
— (no handler, |
Informational event; no subscription mutation needed |
|
— (no handler, |
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-userswith 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:Invokeon thesite-user-serviceAPI 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.
Related Pages
-
Subscription Lifecycle — Full
billing_statusstate machine and all transition sources -
Collections Engine — How the collections engine acts on
updated_eventflags set by this Lambda -
ACH Processing — The
ach-handlerLambda that handles post-collection status updates -
DynamoDB Tables —
billing-activitytable schema, GSIs, and access patterns -
Event Flows — Kinesis stream topology and all consumed event sources
-
Architecture — Lambda function inventory and system context