: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.