DynamoDB Tables
The Subscription Service uses three DynamoDB tables, all in the region given by the dynamo_legacy_region Terraform variable (passed to Lambdas as DYNAMO_DB_REGION / DYNAMO_REGION / DYNAMO_LOCK_REGION): billing-activity (primary subscription records), billing-activity-history (append-only audit mirror), and locks (distributed billing locks).
billing-activity
The primary subscription table. Each record represents one billing period for a user, from creation through a terminal state. A new record is written once the previous billing period has been attempted at least once, advancing the user to the next cycle. Records are mutated in place as collection progresses. Single-record writes (Add) put the billing-activity-history record before the primary record, preserving an audit trail; bulk writes (AddAll) put both tables' records in one BatchWriteItem, which guarantees neither ordering nor atomicity.
Primary key: user_id (PK) + billing_date (SK)
| Attribute | Go Type | Description |
|---|---|---|
|
|
Owning user’s ID |
|
|
Subscription due date (RFC3339Nano string); immutable once written — due-date changes require delete-then-put |
|
|
UUID for this billing record; key of the |
|
|
Dollar amount charged for this period (e.g. |
|
|
Current status — |
|
|
|
|
|
Processor confirmation ID of the most recent attempt; each new attempt overwrites it |
|
|
Payment error message from the most recent failed attempt; empty on |
|
|
First collection attempt for this period |
|
|
When the record reached a terminal status |
|
|
Most recent update; set before every write so history rows are distinct |
|
|
When this record was created |
|
|
Collection process that last updated this record — |
|
|
Lifecycle update tag — |
|
|
Billing term — |
|
|
Last four digits of the bank account used; for receipt display |
|
|
Tier and version at collection time, |
|
|
Payment method type used (e.g. |
|
|
When set, the user is on Friday billing; which Friday of the month (1–5) |
|
|
Months paused; non-zero when status is |
|
|
|
Access patterns
| Operation | Table | Index / key | Used by |
|---|---|---|---|
|
both |
PutItem to history, then primary ( |
All writers |
|
|
|
api, kinesis-feeder |
|
|
PK |
api |
|
|
PK |
api, collections |
|
|
|
collections-job |
|
|
|
collections-job |
|
|
|
collections-job |
|
|
|
notifier-scheduler |
|
both |
DeleteItem on the original key, then |
api |
|
both |
BatchWriteItem — full records to history, |
memberships |
History |
|
|
api (history endpoint) |
Source: pkg/dynamo/subscriptions/
GSIs
Both GSIs project all attributes.
| Index | Purpose |
|---|---|
|
Look up one subscription by UUID ( |
|
Page all subscriptions in a given status; callers narrow with a |
billing_status values
| Value | Description |
|---|---|
|
Awaiting the first collection attempt; all new subscriptions start here |
|
ACH debit submitted; pending settlement from the |
|
Collection succeeded. Terminal |
|
Most recent attempt failed; eligible for retry collection |
|
Manually waived without payment. Terminal |
|
Cancelled before a collection attempt completed. Terminal |
|
Billing paused for |
|
A paused record whose window expired was skipped and a new record created. Terminal for this record |
|
Computed on read for records past the |
|
Written by the batch-worker when the user account is not |
|
Payment refunded after completion; written by the ACH Handler on |
Source: pkg/dynamo/subscriptions/subscriptions.go; REFUNDED is written via pkg/payments/achhandler.go
process values
| Value | Description |
|---|---|
|
First scheduled collection attempt (collections-job, |
|
Daily retry attempt on an |
|
Pause collection run advancing a past-due |
|
Collection triggered by a new-account balance event (webhook-balance-worker) |
|
Collection triggered by the batch-worker Lambda via SQS |
|
Reactivation charge collected synchronously by |
|
Manual repayment via |
|
Process could not be determined from context |
Source: pkg/dynamo/subscriptions/subscriptions.go
updated_event values
| Value | Description |
|---|---|
|
A paused record was skipped because its pause window expired |
|
Paused and approaching its resume date; a transition is pending |
|
Resumed from a paused state |
|
Updated by a user reactivation event from the memberships Kinesis consumer |
|
Cancel requested (memberships |
|
Pending cancellation completed by the collections run |
|
Pause recorded by the memberships |
|
Unpause recorded by the memberships |
|
Account closure recorded by the memberships |
|
Settlement outcome written by the ach-handler |
|
Chargeback recorded by the ach-handler |
|
User not |
|
User ban |
The column is free-form; this table lists the values current code writes. Source: constants in pkg/dynamo/subscriptions/subscriptions.go and at the write sites (pkg/memberships/*.go, pkg/payments/achhandler.go, pkg/collections/batch-worker.go)
billing-activity-history
Append-only audit log of every subscription state change. Same Subscription struct and attributes as billing-activity, but keyed user_id (PK) + last_run_date (SK) — each update advances last_run_date before writing, so every state change creates a new row. data_-prefixed fields stripped from the primary table are retained here. Read via the identical subscription_id-index GSI ([gsis]).
Decision context (data_* attributes)
The history row written when a payment is submitted carries the attempt’s decision context, captured by the CollectionDecision struct embedded in Subscription and flattened into top-level data_* attributes. RemoveDataFields strips them from every primary-table write (Add, AddAll, QAAddAllSubRecords), so they exist only on payment-submission history rows; later rows for the same subscription (settlement, chargeback) are built from the stripped primary row and do not carry them. A row with data_payment_method present is a payment-submission row. Pointer-typed attributes are omitted when the input was never consulted — an explicit 0/false is a real observation. Returns are attributed in the warehouse by joining the row’s transaction_id to the payments record’s confirmation_id.
| Attribute | Type | Description |
|---|---|---|
|
|
Rail attempted: |
|
|
|
|
|
Plaid institution at decision time ( |
|
|
Observed balances in whole cents; calc-available rides only on webhook balance events |
|
|
Which balance the gate evaluated: |
|
|
Gate threshold in force, in whole cents (webhook only; the scheduled and retry gates compare against |
|
|
Debit-card validity when looked up |
|
|
Blocklist state when consulted |
|
|
Scheduled only: the ML decider overrode a failed balance check |
|
|
Scheduled/retry: institution is in |
Per-path coverage (— = not consulted on that path):
| Attribute | webhook | scheduled | retry | batch | manual |
|---|---|---|---|---|---|
|
✓ |
✓ |
✓ |
✓ |
✓ |
|
✓ |
✓ |
✓ |
— |
✓ |
balances + |
✓ |
✓ (no calc) |
✓ (no calc) |
— |
— |
|
✓ |
— |
— |
— |
— |
|
✓ |
pinless-list path only |
— |
— |
✓ |
|
✓ |
✓ |
✓ |
— |
— |
|
— |
✓ |
— |
— |
— |
|
— |
✓ |
✓ |
— |
— |
Capture is passive: it records only what the decision computed anyway and never adds a service call. A new gate state gets a new data_* bool attribute, never a coded value in an existing one. Multi-attempt flows write one self-describing row per attempt; that distinctness depends on last_run_date being stamped freshly per attempt.
Source: pkg/dynamo/subscriptions/subscriptions.go (CollectionDecision); populated in pkg/collections/ and pkg/api/collections.go.
locks
Distributed locking table serializing billing work per user. Acquired by collections-worker, batch-worker, webhook-balance-worker, the memberships handlers (except CLOSEACCOUNT), and the api Lambda (manual pay); the ach-handler writes settlement outcomes without the lock. Backed by cirello.io/dynamolock/v2; attributes are managed entirely by the library.
Primary key: PartitionKey (PK, string; no sort key), format subscription-billing:user_id:{userID}.
| Attribute | Description |
|---|---|
|
Lock key ( |
|
Unix-nanosecond acquisition timestamp (bytes) |
|
TTL attribute for automatic expiry |
|
Lease length (60 seconds) |
|
Lock-owning Lambda invocation |
|
Optimistic-locking version counter |
Lock behavior: 60-second lease, 1-second heartbeat, fail-if-locked — LockSubscriptionForBilling returns ErrAlreadyLocked immediately when the lock is held, and the caller skips the attempt without modifying the subscription. Only one invocation processes a given user’s subscription at a time, even when scheduled and webhook paths fire simultaneously.
Source: pkg/dynamo/lock.go