DynamoDB Tables
The Subscription Service uses three DynamoDB tables. All three are legacy tables created before the current deployment structure was adopted, and are accessed via the DYNAMO_LEGACY_REGION (the dynamo_legacy_region Terraform variable). The primary subscription table (billing-activity) and its history mirror (billing-activity-history) store every subscription record written or updated by the service. The third table (locks) provides distributed locking used by all collection Lambdas to prevent concurrent billing attempts on the same user.
Region for all tables: dynamo_legacy_region Terraform variable (passed to Lambdas as DYNAMO_DB_REGION / DYNAMO_REGION / DYNAMO_LOCK_REGION env vars).
billing-activity
Region: DYNAMO_LEGACY_REGION (env: DYNAMO_DB_REGION / DYNAMO_REGION)
The primary subscription table. Each record represents one billing period for a user — from the moment a new subscription is created through its final terminal state (COMPLETED, WAIVED, CANCELLED, or STALE). A new record is written when the previous billing period has been attempted at least once, advancing the user to the next cycle. Records are mutated in place as the collection engine progresses through states; every write is also mirrored to billing-activity-history before the primary write, so the history table preserves a full audit trail of every state change.
Primary key: user_id (PK) + billing_date (SK)
| Attribute | DynamoDB Tag | Go Type | Description |
|---|---|---|---|
|
PK |
|
Owning user’s ID. |
|
SK |
|
The subscription due date (RFC3339Nano string in DynamoDB). Acts as the sort key. Updated records that change the due date require a delete-then-put because the SK is immutable once written. |
|
|
|
UUID that uniquely identifies this subscription billing record. Used as the key for the |
|
|
|
Dollar amount charged for this subscription period (e.g. |
|
|
|
Current status of the subscription. See Billing Status Values. |
|
|
|
|
|
|
|
Payment processor confirmation ID for the most recent collection attempt. On a completed record this is the final confirmation ID. On in-progress records each new attempt overwrites the previous value. |
|
|
|
Payment error message from the most recent failed attempt (from JPM or USIO). Cleared to empty when the record reaches |
|
|
|
Timestamp of the first collection attempt for this billing period. |
|
|
|
Timestamp when the subscription reached a terminal status ( |
|
|
|
Timestamp of the most recent update or collection attempt. Must be set before writing so that |
|
|
|
Timestamp when this billing record was first created. |
|
|
|
The collection stage or process that last updated this record. See Process Values. |
|
|
|
Internal event tag describing a specific lifecycle update. See Updated Event Values. |
|
|
|
Billing term. |
|
|
|
Last four digits of the bank account used for this collection. Stored for receipt display. |
|
|
|
Membership tier and version at time of collection, encoded as |
|
|
|
Payment method type used for this collection (e.g. |
|
|
|
Optional. When set, indicates the user is on Friday billing and records which Friday of the month (1–5) their billing falls on. |
|
|
|
Number of months the subscription is paused. Non-zero when |
|
|
|
|
Access Patterns
Add a new subscription record (also writes to history table):
Add(ctx, subscription) → PutItem on billing-activity-history (full record including data_ fields) → PutItem on billing-activity (data_ prefixed fields stripped via RemoveDataFields)
Get a subscription by its UUID (via GSI):
GetByID(ctx, subscriptionID)
→ Query(
table=billing-activity,
index=subscription_id-index,
key=subscription_id = subscriptionID
)
List all subscriptions for a user (table scan of user’s partition):
ListByUserID(ctx, userID)
→ Query(
table=billing-activity,
key=user_id = userID,
ScanIndexForward=true, // ascending by billing_date
paginated until LastEvaluatedKey is empty
)
Get recent subscriptions (reverse order, single page):
GetRecentSubscriptions(ctx, userID)
→ Query(
table=billing-activity,
key=user_id = userID,
ScanIndexForward=false // descending — most recent first
)
Get a page of scheduled subscriptions due before date (via GSI, used by collections-job):
GetScheduledSubscriptionPage(ctx, date, startKey)
→ Query(
table=billing-activity,
index=billing_status-index,
key=billing_status = "SCHEDULED",
filter=billing_date < date
)
Get a page of subscriptions in ERROR status with billing_date after date (retry collections):
GetRetrySubscriptionPage(ctx, date, startKey)
→ Query(
table=billing-activity,
index=billing_status-index,
key=billing_status = "ERROR",
filter=billing_date > date
)
Get a page of PAUSED subscriptions with billing_date before date:
GetPausedSubscriptionPage(ctx, date, startKey)
→ Query(
table=billing-activity,
index=billing_status-index,
key=billing_status = "PAUSED",
filter=billing_date < date
)
Get a page of subscriptions by status within a billing date range (used by notifier-scheduler):
GetNotificationSubscriptionPage(ctx, status, billingDate, billingEndDate, startKey)
→ Query(
table=billing-activity,
index=billing_status-index,
key=billing_status = status,
filter=billing_date >= billingDate AND billing_date < billingEndDate
)
Admin update (delete-then-put, used when billing_date changes):
AdminUpdate(ctx, original, updated)
→ DeleteItem(key={user_id, billing_date} of original)
→ Add(ctx, updated) // PutItem to both tables
Batch write multiple subscriptions (upgrade/downgrade processing):
AddAll(ctx, subscriptions)
→ BatchWriteItem on both billing-activity and billing-activity-history
(SK collision jiggle: non-SCHEDULED subs with duplicate billing_date
get +N hours added to billing_date to avoid DynamoDB key conflicts)
GSIs
subscription_id-index
Projection: all attributes.
| Key | Description |
|---|---|
|
Lookup a single subscription by UUID. Returns at most one item. Used by |
billing_status-index
Projection: all attributes.
| Key | Description |
|---|---|
|
Scan all subscriptions with a given status. Used by |
billing_status-index returns all items globally for a given status, so callers apply a filter expression on billing_date to narrow results. Filter expressions do not reduce consumed capacity — they are applied after read.
|
billing_status Values
| Value | Description |
|---|---|
|
The subscription is awaiting its first collection attempt. All new subscriptions start in this state. |
|
An ACH debit has been submitted to the payment processor and is pending settlement confirmation from the |
|
The collection succeeded. Terminal state. |
|
The most recent collection attempt failed (e.g. insufficient balance, ACH return). Eligible for retry collection. |
|
The subscription was manually waived (forgiven) without payment. Terminal state. |
|
The subscription was cancelled before a collection attempt completed. Terminal state. |
|
The subscription’s billing is paused for |
|
The subscription was in a PAUSED state whose pause window has expired; it was skipped and a new subscription record was created. Terminal state for this record. |
|
The subscription payment was refunded after successful ACH collection. Set by the ach-handler on a |
|
The subscription is too old to collect (past the |
|
Internal sentinel value used to represent the absence of an active subscription. |
process Values
| Value | Description |
|---|---|
|
First scheduled collection attempt, triggered by |
|
Daily retry attempt on an ERROR subscription, triggered by |
|
Pause collection run, triggered by |
|
Collection triggered by an income-detection event (webhook-worker Lambda consuming EventBridge income events). |
|
Collection triggered by a new-account balance event (webhook-balance-worker Lambda). |
|
Collection triggered by the |
|
Subscription record written as part of a user reactivation flow. |
|
Manual repayment triggered via the |
|
Process could not be determined from the available context. |
updated_event Values
| Value | Description |
|---|---|
|
The subscription was in a PAUSED state that was skipped because the pause window expired. |
|
The subscription is paused and approaching its resume date; a notification or state transition is pending. |
|
The subscription has been resumed from a paused state. |
|
The subscription record was updated as part of a user reactivation event from the memberships Kinesis consumer. |
billing-activity-history
Region: DYNAMO_LEGACY_REGION (env: DYNAMO_DB_REGION / DYNAMO_REGION)
An append-only audit log of every subscription state change. Uses the same Subscription struct and the same attribute names as billing-activity, but with a different primary key structure: user_id (PK) + last_run_date (SK). This means each update to a subscription — which advances last_run_date before writing — creates a new history row rather than overwriting the previous state. When bulk-writing via AddAll, both tables receive the same BatchWriteItem request.
Primary key: user_id (PK) + last_run_date (SK)
All attributes are identical to billing-activity. The data_ prefixed fields (stripped from billing-activity before write via RemoveDataFields) are retained in billing-activity-history when written by the Add method.
|
Access Patterns
Get all history entries for a subscription UUID (via GSI):
GetByID(ctx, subscriptionID)
→ Query(
table=billing-activity-history,
index=subscription_id-index,
key=subscription_id = subscriptionID,
paginated until LastEvaluatedKey is empty
)
Append a single history record:
Add(ctx, subscription) → PutItem on billing-activity-history
Batch-append multiple history records:
AddAll(ctx, subscriptions) → BatchWriteItem on billing-activity-history
GSIs
subscription_id-index
Identical definition to the subscription_id-index GSI on billing-activity. Projection: all attributes.
| Key | Description |
|---|---|
|
Retrieve all history entries for a given subscription UUID, ordered by |
locks
Region: DYNAMO_LEGACY_REGION (env: DYNAMO_LOCK_REGION)
Distributed locking table used by all collection Lambdas (collections-worker, batch-worker, webhook-worker, webhook-balance-worker) to prevent concurrent billing attempts on the same user. Backed by the cirello.io/dynamolock/v2 client. Attributes in this table are managed entirely by the dynamolock library; the service does not read them directly.
Primary key: PartitionKey (PK — string, no sort key)
| Attribute | DynamoDB Tag | Description |
|---|---|---|
|
PK |
Lock key in the format |
|
— |
Unix nanosecond timestamp (as bytes) of when the lock was acquired. Written by |
|
— |
TTL attribute for automatic record expiry. Managed by the dynamolock library. |
|
— |
Duration of the lock lease. Set to 60 seconds via |
|
— |
Identifier of the lock owner (Lambda invocation). Managed by the dynamolock library. |
|
— |
Version counter used for optimistic locking. Prevents split-brain when two invocations attempt to acquire the same lock simultaneously. |
Lock Behaviour
A lock is acquired with:
-
Lease duration: 60 seconds
-
Heartbeat period: 1 second (the Lambda refreshes the lease every second while holding it)
-
Fail-if-locked: yes — if the lock is already held by another invocation,
LockSubscriptionForBillingreturnsErrAlreadyLockedimmediately rather than waiting
LockSubscriptionForBilling(ctx, userID)
→ AcquireLockWithContext(
key = "subscription-billing:user_id:" + userID,
data = UnixNano timestamp bytes,
FailIfLocked = true,
ReplaceData = true
)
→ returns *dynamolock.Lock or ErrAlreadyLocked
ReleaseSubscription(ctx, lock)
→ ReleaseLockWithContext(lock)
When a lock cannot be acquired, the collection attempt is skipped without modifying the subscription record. This ensures only one Lambda processes a given user’s subscription at any time, even when scheduled and webhook collection paths fire simultaneously.
Related Pages
-
Architecture — DynamoDB tables in the system context
-
Collections Engine — How
billing_status-indexqueries and locks are used during scheduled, retry, pause, and webhook collection flows -
Subscription Lifecycle — Full status transition diagram and descriptions
-
ACH Processing — How ACH Kinesis events drive
billing_statustransitions fromACHSENTtoCOMPLETED/ERROR -
Infrastructure — Terraform definitions for Lambda IAM permissions and table region configuration