:PROPERTIES:
:ID: 6af95849-8f78-4697-ab48-3712ff2f5ee1
:END:
#+title: Stripe payments tracking database
#+OPTIONS: ^:nil
Database for tracking payments and subscriptions managed by the [[id:9b3ed74d-41d4-4784-89e3-6a9183903b9e][Stripe Payments Service]].
* State Tracking
** Legacy purchase
- Client initiates purchase
- Backend coordinates with Stripe and returns a payment intent
+ Track new purchase and related incomplete payment
- Client completes purchase with Stripe
- Stripe notifies backend of payment intent status (update purchase)
+ Track event
+ Track payment as completed
+ Fulfill purchase
+ Track purchase as fulfilled
#+begin_src plantuml :file stripe-legacy-purchase-tracking.svg
participant Stripe
actor Client
participant "stripe-payments" as Backend
database Tracking
Client -> Backend: Initiate purchase
Backend -> Tracking : Store incomplete purchase
Backend -> Stripe : Create payment intent
Backend -> Tracking : Store incomplete payment
Backend -> Client : Return payment intent
Client -> Stripe : Complete purchase
...
alt Success
Stripe ---> Backend : payment_intent.succeeded
Backend -> Tracking : Store event
alt Event not previously handled
Backend -> Tracking : Mark payment as succeeded
Backend -> Backend : Fulfill purchase
Backend -> Tracking : Mark purchase as fulfilled
end
else Failure
Stripe --> Backend : payment_intent.payment_failed
Backend -> Tracking : Store event
end
#+end_src
#+RESULTS:
[[file:stripe-legacy-purchase-tracking.svg]]
#+caption: Legacy purchase flow with events
#+RESULTS:
** Product purchase
- Client prepares payment method with Stripe
- Client initiates purchase
- Backend coordinates with Stripe to complete the purchase
+ Attaches the payment method to the customer
+ For single products
- Creates an invoice item
- Creates an invoice
- Pays the invoice
+ For subscriptions
- Creates the subscription
#+begin_src plantuml :file stripe-purchase-tracking.svg
participant Stripe
actor Client
participant "stripe-payments" as Backend
database Tracking
Client -> Stripe : Create payment method
Client -> Backend: Initiate purchase
Backend -> Tracking : Store incomplete purchase
alt Non-recurring
Backend -> Stripe : Create invoice item
Backend -> Stripe : Create invoice
Backend -> Stripe : Pay invoice
Backend -> Tracking : Store completed payment
else Recurring
Backend -> Stripe : Create subscription
Backend -> Tracking : Store active subscription
end
...
Stripe --> Backend : Subscription activated
...
alt
Stripe --> Backend : customer.subscription.updated (no longer active)
else
Stripe --> Backend : subscription_schedule.canceled
end
Backend -> Tracking : Store event
Backend -> Backend : Trigger unsubscribe actions
Backend -> Tracking : Mark subscription as terminated
#+end_src
#+caption: Product purchase flow
#+RESULTS:
[[file:stripe-purchase-tracking.svg]]
#+begin_src plantuml :file stripe-purchase-tracking-payment-events.svg
participant Stripe
actor Client
participant "stripe-payments" as Backend
database Tracking
== Payment succeeded ==
Stripe --> Backend : payment_intent.succeeded
Backend -> Tracking : Store event
alt Event not previously handled
Backend -> Tracking : Mark payment as succeeded
Backend -> Stripe : Look up invoice
note over Backend
Recurring if an invoice exists
and has an associated subscription
end note
alt Non-recurring
Backend -> Backend : Fulfill purchase
Backend -> Tracking : Mark purchase as fulfilled
else Recurring
end
end
== Payment failed ==
Stripe --> Backend : payment_intent.payment_failed
Backend -> Tracking : Store event
#+end_src
#+caption: Payment events
#+RESULTS:
[[file:stripe-purchase-tracking-payment-events.svg]]
#+begin_src plantuml :file stripe-purchase-subscription-events.svg
participant Stripe
actor Client
participant "stripe-payments" as Backend
database Tracking
== Subscription activated ==
alt
Stripe --> Backend : customer.subscription.created (active)
else
Stripe --> Backend : customer.subscription.updated (active)
end
Backend -> Tracking : Store event
alt New subscription
Backend -> Tracking : Mark subscription as active
Backend -> Backend : Fulfill purchase
Backend -> Tracking : Mark purchase as fulfilled
else Subscription already processed
note over Backend
One of the following is true:
- Already received an activation event for this subscription
- Already receieved a termination event for this subscription
- Subscription already marked as active or terminated
- Purchase already marked as fulfilled
end note
end
== Subscription terminated ==
alt
Stripe --> Backend : customer.subscription.updated (no longer active)
else
Stripe --> Backend : subscription_schedule.canceled
end
Backend -> Tracking : Store event
alt Active subscription
Backend -> Backend : Trigger unsubscribe actions
Backend -> Tracking : Mark subscription as terminated
else Subscription not yet processed
note over Backend
One of the following is true:
- Did not receive an activation event for this subscription
- Subscription is not tracked
end note
else Subscription already terminated
note over Backend
One of the following is true:
- Already received a termination event for this subscription
- Subscription already marked as terminated
end note
end
#+end_src
#+caption: Subscription events
#+RESULTS:
[[file:stripe-purchase-subscription-events.svg]]
* Tables
** Purchases
Purchases made via the [[id:9b3ed74d-41d4-4784-89e3-6a9183903b9e][Stripe Payments Service]].
#+begin_src plantuml :file stripe-purchase-states.svg
[*] -> New
New -> Fulfilled
#+end_src
#+caption: Purchase state diagram
#+RESULTS:
[[file:stripe-purchase-states.svg]]
#+caption: Purchases
| Field | Type | Nullable | Description |
|----------------+-----------+----------+--------------------------------------------|
| id | UUID | N | Auto-generated purchase ID |
| created | timestamp | N | Time the purchase was initiated |
| last_updated | timestamp | N | Time the purchase was last updated |
| account | UUID | N | The AWeber customer account purchased from |
| stripe_account | text | N | The Stripe account of the AWeber customer |
| fulfilled | boolean | N | Was this purchase fulfilled |
- Store which automations were applied & when
** Subscriptions and Split Payments
Purchases made with recurring payments, managed using a Stripe Subscription.
#+begin_src plantuml :file stripe-subscription-states.svg
[*] -> New
New -> Active
Active --> Terminated : Payment Failed
Active --> Terminated : Unsubscribed
New --> Terminated : Payment Failed
#+end_src
#+caption: Subscription state diagram
#+RESULTS:
[[file:stripe-subscription-states.svg]]
#+caption: Subscriptions
| Field | Type | Nullable | Description |
|----------------+---------------------+----------+---------------------------------------------------------|
| id | text | N | The Stripe subscription ID |
| created | timestamp | N | Time the purchase was initiated |
| last_updated | timestamp | N | Time the purchase was last updated |
| account | UUID | N | The AWeber customer account the subscription belongs to |
| stripe_account | text | N | The Stripe account of the AWeber customer |
| status | [[subscription_status][subscription_status]] | N | Status of the subscription |
<>
#+caption: ENUM: Subscription Status
| new |
| active |
| terminated |
** Payments
Payments collected from buyers.
#+begin_src plantuml :file stripe-payment-states.svg
[*] -> New
New --> Paid : Payment succeeded
New --> Failed : Payment failed
#+end_src
#+caption: Payment state diagram
#+RESULTS:
[[file:stripe-payment-states.svg]]
#+caption: Payments
| Field | Type | Nullable | Description |
|----------------+----------------+----------+-------------------------------------------|
| id | text | N | The Stripe payment intent ID |
| created | timestamp | N | Time the purchase was initiated |
| last_updated | timestamp | N | Time the purchase was last updated |
| account | UUID | N | The AWeber customer account that was paid |
| stripe_account | text | N | The Stripe account of the AWeber customer |
| purchase | UUID | N | |
| status | [[payment_status][payment_status]] | N | Status of the payment intent |
<>
#+caption: ENUM: Payment Status
| new |
| paid |
| failed |
** Events
#+caption: Events
| Field | Type | Nullable | Description |
|----------------+-----------+----------+---------------------------------------------------------|
| id | text | N | Stripe event id |
| type | text | N | Stripe event type |
| account | UUID | N | The AWeber customer account the subscription belongs to |
| stripe_account | text | N | The Stripe account of the AWeber customer |
| timestamp | timestamp | N | Time event was published |
| subscription | text | Y | Related Stripe subscription ID |
Events should expire out of the database over time. Stripe maintains events in
its database for up to 30 days.
*** Modeling in DynamoDB
**** Table
- Partition Key :: =id=
The table index provides for rapid lookup of individual events.
**** Attributes
| Field | Type | Required | Description |
|----------------+-------------------------+----------+-----------------------------------------------------------|
| id | String | Y | Stripe event id |
| type | String | Y | Stripe event type |
| stripe_account | String | Y | The Stripe account of the AWeber customer |
| timestamp | String (timestamp) | Y | Time event was published (e.g. "2020-01-01 01:02:34.567") |
| date | String (date) | Y | Date portion of timestamp (e.g. "2020-01-01") |
| time | String (time) | Y | Time portion of timestamp (e.g. "01:02:34.567") |
| expiration | Number (Unix timestamp) | Y | TTL expiration time of the event record |
| subscription | String | N | Related Stripe subscription ID |
**** TTL
The events table will automatically expire and remove rows for which
=expiration= has passed. We may set =expiration= to be the event creation time
plus thirty days to match Stripe's own expiration. This will prevent old items
from piling up in the table.
**** Time index (GSI)
- Partition Key :: =date=
- Sort Key :: =time=
- Projection :: Keys Only
The date and time portions of the event timestamp will be separated to allow for
scanning all events within a day, filterable by time. This should support our
polling job, which should run multiple times per day. We'll have to be careful
around date boundaries when looking up events (e.g. by including a query for
events from the previous day during the first run of the current day).
**** Stripe Account index (GSI)
- Partition Key :: =stripe_account=
- Sort Key :: =timestamp=
- Projection :: Include type, subscription
The account index provides for rapid lookup of events for a particular stripe
account, filterable by timestamp.
**** Subscription index (GSI)
- Partition Key :: =subscription=
- Sort Key :: =timestamp=
- Projection :: Include type, stripe_account
The account index provides for rapid lookup of events for a particular
subscription, filterable by timestamp.
**** IAM Policy
Put together by referencing [[id:ab2d34bf-97b1-4e50-8e9a-597d0f8fcf01][DynamoDB IAM Policies]].
#+caption: Stripe Payments DynamoDB IAM Policy
#+begin_src json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "dynamodb:ListTables",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
"dynamodb:Query",
"dynamodb:UpdateItem",
"dynamodb:DescribeTimeToLive",
"dynamodb:CreateTable",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:UpdateTable",
"dynamodb:UpdateTimeToLive"
],
"Resource": [
"arn:aws:dynamodb:*:018154689201:table/*-stripe-payments-*/index/*",
"arn:aws:dynamodb:*:018154689201:table/*-stripe-payments-*"
]
},
]
}
#+end_src
* Notes
- How does the [[id:83d61eef-0781-46e0-b959-1a739cff5ea3][poller]] use these stored events?
+ /To identify events retrieved from Stripe which do not need to be replayed/
- How do we replay events?
+ Send it to the webhook endpoint? An internal webhook endpoint that shares
code with the public one?
- /The same webhook endpoint would be preferable, requires signing the
payload/
- Do we expose the processed events as an internal endpoint in the
stripe-payments service, or give the polling app a database connection?
+ /API seems preferable/
Following some discussion, the [[Events][Events]] table should be sufficient for now.