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

user_id (PK)

string

Owning user’s ID

billing_date (SK)

time.Time

Subscription due date (RFC3339Nano string); immutable once written — due-date changes require delete-then-put

subscription_id

string

UUID for this billing record; key of the subscription_id-index GSI

billing_amount

string

Dollar amount charged for this period (e.g. "4.99")

billing_status

string

Current status — billing_status values

billing_period

string

MM/YYYY form of billing_date; informational, not indexed

transaction_id

string

Processor confirmation ID of the most recent attempt; each new attempt overwrites it

usio_error

string

Payment error message from the most recent failed attempt; empty on COMPLETED. Also carries a block_reason when a pinless submit was blocked (Retry-Limit Blocks)

initial_run_date

time.Time

First collection attempt for this period

completion_date

time.Time

When the record reached a terminal status

last_run_date

time.Time

Most recent update; set before every write so history rows are distinct

created_date

time.Time

When this record was created

process

string

Collection process that last updated this record — process values

updated_event

string

Lifecycle update tag — updated_event values

term

string

Billing term — term values

receipt_account_mask

string

Last four digits of the bank account used; for receipt display

receipt_tier_name

string

Tier and version at collection time, "TierName:Version" (split via GetTier() / GetVersion())

receipt_payment_type

string

Payment method type used (e.g. ACH); for receipt display

billing_week

*int

When set, the user is on Friday billing; which Friday of the month (1–5)

pause_duration_months

int

Months paused; non-zero when status is PAUSED or PAUSED_SKIPPED

is_pending_downgrade

bool

true when a downgrade is requested but not yet applied (omitted when false)

Access patterns

Operation Table Index / key Used by

Add

both

PutItem to history, then primary (RemoveDataFields strips data_ fields from the primary write)

All writers

GetByID

billing-activity

subscription_id-index, subscription_id = :id

api, kinesis-feeder

ListByUserID

billing-activity

PK user_id, ascending by SK, paginated

api

GetRecentSubscriptions

billing-activity

PK user_id, descending, single page

api, collections

GetScheduledSubscriptionPage

billing-activity

billing_status-index = SCHEDULED, filter billing_date < :date

collections-job

GetRetrySubscriptionPage

billing-activity

billing_status-index = ERROR, filter billing_date > :date

collections-job

GetPausedSubscriptionPage

billing-activity

billing_status-index = PAUSED, filter billing_date < :date

collections-job

GetNotificationSubscriptionPage

billing-activity

billing_status-index = :status, filter billing_date in [start, end)

notifier-scheduler

AdminUpdate

both

DeleteItem on the original key, then Add (SK is immutable)

api

AddAll

both

BatchWriteItem — full records to history, data_-stripped copies to primary; non-SCHEDULED rows with a duplicate billing_date SK are offset by +N hours

memberships

History GetByID

billing-activity-history

subscription_id-index, paginated

api (history endpoint)

Source: pkg/dynamo/subscriptions/

GSIs

Both GSIs project all attributes.

Index Purpose

subscription_id-index

Look up one subscription by UUID (GetByID); also used by kinesis-feeder when processing stream events. An identical GSI on billing-activity-history returns all history rows for a UUID

billing_status-index

Page all subscriptions in a given status; callers narrow with a billing_date filter expression. Used by collections-job (SCHEDULED/ERROR/PAUSED scans) and notifier-scheduler

billing_status values

Value Description

SCHEDULED

Awaiting the first collection attempt; all new subscriptions start here

ACHSENT

ACH debit submitted; pending settlement from the site-payments Kinesis stream

COMPLETED

Collection succeeded. Terminal

ERROR

Most recent attempt failed; eligible for retry collection

WAIVED

Manually waived without payment. Terminal

CANCELLED

Cancelled before a collection attempt completed. Terminal

PAUSED

Billing paused for pause_duration_months months; pause collections advance past-due PAUSED records

PAUSED_SKIPPED

A paused record whose window expired was skipped and a new record created. Terminal for this record

STALE

Computed on read for records past the OLD_SUBSCRIPTION_DAYS threshold; never stored in DynamoDB

INACTIVE

Written by the batch-worker when the user account is not ACTIVE (updated_event: inactive); also returned as a read-path sentinel when a user has no active record

REFUNDED

Payment refunded after completion; written by the ACH Handler on SUBSCRIPTION_REFUNDED events (ACH Callbacks)

Source: pkg/dynamo/subscriptions/subscriptions.go; REFUNDED is written via pkg/payments/achhandler.go

process values

Value Description

INITIAL

First scheduled collection attempt (collections-job, SCHEDULED scan)

RETRY

Daily retry attempt on an ERROR subscription (collections-job, ERROR scan)

PAUSE

Pause collection run advancing a past-due PAUSED subscription (collections-job)

WEBHOOK_BALANCE

Collection triggered by a new-account balance event (webhook-balance-worker)

BATCH

Collection triggered by the batch-worker Lambda via SQS

REACTIVATION

Reactivation charge collected synchronously by POST /{user_id}/subscriptions/reactivate (Reactivation)

MANUAL_REPAYMENT

Manual repayment via POST /{user_id}/subscriptions/{subscription_id}/pay

NOT_IDENTIFIED

Process could not be determined from context

Source: pkg/dynamo/subscriptions/subscriptions.go

updated_event values

Value Description

pause-skipped

A paused record was skipped because its pause window expired

pause-pending-resume

Paused and approaching its resume date; a transition is pending

pause-resume

Resumed from a paused state

user-reactivated

Updated by a user reactivation event from the memberships Kinesis consumer

PENDING_CANCELLATION

Cancel requested (memberships CANCEL handler); the next collections run sets CANCELLED

cancel-successful

Pending cancellation completed by the collections run

SUB_PAUSED

Pause recorded by the memberships SUB_PAUSED handler

UNPAUSE

Unpause recorded by the memberships UNPAUSE/UNPAUSE_CHARGE handlers

account-closed

Account closure recorded by the memberships CLOSEACCOUNT handler

payment-completed / payment-failed / payment-refunded

Settlement outcome written by the ach-handler

CHARGED_BACK

Chargeback recorded by the ach-handler

inactive

User not ACTIVE at batch collection time (batch-worker)

user_banned

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)

term values

Value Description

MONTHLY

Renews monthly; GetNextSubscriptionDate advances billing_date by one month. Default

YEARLY

Renews annually; advances billing_date by 12 months

Source: pkg/dynamo/subscriptions/subscriptions.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

data_payment_method

S

Rail attempted: ach or pinless

data_is_fallback

BOOL

true when the user’s preferred ACH was gated out and the attempt came from the regular-process fallback (collections.adoc#webhook-balance); the gate fields describe the rail actually attempted

data_institution_id

S

Plaid institution at decision time (ins_*, or unknown)

data_balance_available_cents / data_balance_current_cents / data_balance_calc_available_cents

N

Observed balances in whole cents; calc-available rides only on webhook balance events

data_balance_source

S

Which balance the gate evaluated: available, current, or calc_available

data_balance_threshold_cents

N

Gate threshold in force, in whole cents (webhook only; the scheduled and retry gates compare against billing_amount)

data_is_card_valid

BOOL

Debit-card validity when looked up

data_is_blocklisted

BOOL

Blocklist state when consulted

data_is_ml_override

BOOL

Scheduled only: the ML decider overrode a failed balance check

data_is_pinless_only_institution

BOOL

Scheduled/retry: institution is in subscriptions.pinless.institutions

Per-path coverage (— = not consulted on that path):

Attribute webhook scheduled retry batch manual

data_payment_method, data_is_fallback

✓

✓

✓

✓

✓

data_institution_id

✓

✓

✓

—

✓

balances + data_balance_source

✓

✓ (no calc)

✓ (no calc)

—

—

data_balance_threshold_cents

✓

—

—

—

—

data_is_card_valid

✓

pinless-list path only

—

—

✓

data_is_blocklisted

✓

✓

✓

—

—

data_is_ml_override

—

✓

—

—

—

data_is_pinless_only_institution

—

✓

✓

—

—

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

PartitionKey

Lock key (buildLockKey in pkg/dynamo/lock.go)

data

Unix-nanosecond acquisition timestamp (bytes)

deleteOn

TTL attribute for automatic expiry

leaseDuration

Lease length (60 seconds)

owner

Lock-owning Lambda invocation

recordVersionNumber

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