mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 15:38:56 +00:00
core: client badge store, plan: tick C1
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeOperators #-}
|
||||
|
||||
-- | The CLIENT's badge store: the user's purchases, the payment row a code redemption writes,
|
||||
-- the issuances the worker collects and the ledger replica the service sends.
|
||||
--
|
||||
-- The tables are the ones @badgeSchema@ creates
|
||||
-- ("Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges"), unprefixed, plus the
|
||||
-- client-only columns that migration ALTERs in: @badge_purchases.user_id@\/@purchase_priv_key@,
|
||||
-- @badge_ledger.entry_type_unknown@\/@entry_type_value@ and @users.shown_badge_id@. The service
|
||||
-- has the same tables under its own prefix, so "BadgeService.Store" is the mirror of this module
|
||||
-- and the column spellings of every status and entry type are deliberately identical to its
|
||||
-- 'BadgeService.Store.encodeLedgerEntryType'\/'BadgeService.Store.decodeLedgerEntryType'; a second
|
||||
-- spelling of @credit@\/@support@\/@opening@\/@lapse@ would be a defect, not a style choice.
|
||||
--
|
||||
-- Two things this module is NOT:
|
||||
--
|
||||
-- * It is not an author of ledger entries. The service writes the ledger (docs\/protocol
|
||||
-- \/badges-rpc.md, "The ledger is written by the service alone"); the client keeps a verbatim
|
||||
-- replica, so 'insertLedgerEntries' copies what it is given and mints nothing. @entry_uuid@ in
|
||||
-- particular is the service's, never generated here.
|
||||
--
|
||||
-- * It opens no transaction. Every function takes a 'DB.Connection', as the rest of
|
||||
-- "Simplex.Chat.Store" does, so a caller composes a payment row, a purchase row, the ledger
|
||||
-- rows, an issuance and the shown-badge pointer into one @withStore@ transaction.
|
||||
module Simplex.Chat.Store.Badges
|
||||
( -- * Purchases and payments
|
||||
UserBadgePurchase (..),
|
||||
BadgeSlot (..),
|
||||
badgeSlot,
|
||||
createCodePayment,
|
||||
createPurchase,
|
||||
supersedePurchases,
|
||||
setShownPurchase,
|
||||
getShownPurchase,
|
||||
|
||||
-- * Issuances
|
||||
NewBadgeIssuance (..),
|
||||
createIssuance,
|
||||
|
||||
-- * Ledger
|
||||
getLastBadgeLedgerEntry,
|
||||
insertLedgerEntries,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (forM_, unless)
|
||||
import Control.Monad.Except (ExceptT, liftEither)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import qualified Data.Aeson as J
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString.Lazy as LB
|
||||
import Data.Int (Int64)
|
||||
import Data.List (isPrefixOf)
|
||||
import Data.Maybe (listToMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import qualified Data.UUID as UUID
|
||||
import qualified Data.UUID.V4 as UUID
|
||||
import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType (..))
|
||||
import Simplex.Chat.Badges.Service
|
||||
( BadgeStatement (..),
|
||||
StatementCreditType (..),
|
||||
StatementDebitType (..),
|
||||
StatementEntry (..),
|
||||
StatementEntryType (..),
|
||||
)
|
||||
import Simplex.Chat.Badges.Types
|
||||
( BadgeIssuance (..),
|
||||
BadgeLedgerEntry (..),
|
||||
BadgePurchaseStatus (..),
|
||||
LedgerCreditType (..),
|
||||
LedgerDebitType (..),
|
||||
LedgerEntryType (..),
|
||||
)
|
||||
import Simplex.Chat.PaymentService.Types (PaymentStatus (..))
|
||||
import Simplex.Chat.Store.Shared (StoreError (..))
|
||||
import Simplex.Messaging.Agent.Protocol (UserId)
|
||||
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
||||
#else
|
||||
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
#endif
|
||||
|
||||
-- Purchases and payments ------------------------------------------------------
|
||||
|
||||
-- | The client's own projection of a @badge_purchases@ row: exactly its columns, with the
|
||||
-- client-only @user_id@ and @purchase_priv_key@ that the service's table does not have.
|
||||
--
|
||||
-- This is deliberately NOT 'Simplex.Chat.Badges.Types.BadgePurchase'. That record declares
|
||||
-- @priceId@, @offerId@ and @credential@, none of which is a column of this table: the price and
|
||||
-- offer of a purchase live on its @badge_invoices@ rows (one per invoice, so not a function of
|
||||
-- the purchase), and the credential lives on its @badge_issuances@ rows (one per period). A row
|
||||
-- type that had to invent three fields to be constructed would report a purchase the database
|
||||
-- does not hold. 'BadgeService.Store.BadgePurchaseRow' is the same decision on the service side.
|
||||
data UserBadgePurchase = UserBadgePurchase
|
||||
{ badgePurchaseId :: Int64,
|
||||
userId :: UserId,
|
||||
purchaseKey :: C.PublicKeyEd25519,
|
||||
purchasePrivKey :: C.PrivateKeyEd25519,
|
||||
masterKey :: BadgeMasterKey,
|
||||
initialBadgeType :: BadgeType,
|
||||
currentBadgeType :: BadgeType,
|
||||
paymentId :: Maybe Text,
|
||||
status :: BadgePurchaseStatus,
|
||||
createdAt :: UTCTime,
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | The badge a purchase occupies on a profile (plan §3): @paid@ for @supporter@ and @legend@,
|
||||
-- @investor@ for @investor@. It is derived from @current_badge_type@; no column stores it. At
|
||||
-- most one purchase per slot is 'PSIssued' at a time, which is what 'supersedePurchases'
|
||||
-- maintains. A badge type this build does not know keeps its own slot rather than joining
|
||||
-- @paid@: superseding a purchase because of a tag we cannot interpret would remove a badge the
|
||||
-- user paid for.
|
||||
data BadgeSlot = BSPaid | BSInvestor | BSOther Text
|
||||
deriving (Eq, Show)
|
||||
|
||||
badgeSlot :: BadgeType -> BadgeSlot
|
||||
badgeSlot = \case
|
||||
BTSupporter -> BSPaid
|
||||
BTLegend -> BSPaid
|
||||
BTInvestor -> BSInvestor
|
||||
BTUnknown tag -> BSOther tag
|
||||
|
||||
-- | @payments.provider@ has no column codec yet: 'Simplex.Chat.PaymentService.Types.PaymentProvider'
|
||||
-- has no 'TextEncoding' instance, and adding one is D0\/E2\/F1's, once a second provider needs
|
||||
-- writing. Until then this literal is the client twin of
|
||||
-- 'BadgeService.Store.codePaymentProviderText' and must keep the same spelling.
|
||||
codePaymentProviderText :: Text
|
||||
codePaymentProviderText = "code"
|
||||
|
||||
-- | The @payments@ row of a code redemption: a caller-minted UUID (the column is
|
||||
-- @TEXT NOT NULL PRIMARY KEY@ with no default), @provider = 'code'@, no invoice — a code
|
||||
-- payment never has one — and @settled@, because a redeemed code is paid for by definition.
|
||||
-- Returns the id, which 'createPurchase' takes.
|
||||
--
|
||||
-- This row is the CLIENT's and is unrelated to the service's own @payments@ row for the same
|
||||
-- redemption: the two databases share only the ledger entries, and even there the client's
|
||||
-- @payment_id@ is NULL (see 'insertLedgerEntries').
|
||||
createCodePayment :: DB.Connection -> UTCTime -> IO Text
|
||||
createCodePayment db now = do
|
||||
paymentId <- UUID.toText <$> UUID.nextRandom
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO payments (payment_id, invoice_id, provider, status, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
|]
|
||||
(paymentId, Nothing :: Maybe Text, codePaymentProviderText, PSSettled, now, now)
|
||||
pure paymentId
|
||||
|
||||
-- | The purchase row of a code redemption, written on success only: a code redemption does not
|
||||
-- learn its badge type until the response arrives, and @initial_badge_type@\/@current_badge_type@
|
||||
-- are both NOT NULL with no default. Nothing is persisted before the send; a response lost in
|
||||
-- flight is recovered with @codes unredeem@, not by reusing a stored key.
|
||||
--
|
||||
-- The status is 'PSIssued' directly, not 'PSAcquiring': the credential is in hand by the time
|
||||
-- this runs.
|
||||
createPurchase :: DB.Connection -> UserId -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> BadgeMasterKey -> BadgeType -> Text -> UTCTime -> IO UserBadgePurchase
|
||||
createPurchase db userId purchaseKey purchasePrivKey masterKey@(BadgeMasterKey mk) badgeType paymentId now = do
|
||||
[Only badgePurchaseId] <-
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO badge_purchases
|
||||
(user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, payment_id, status, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING badge_purchase_id
|
||||
|]
|
||||
((userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, paymentId) :. (PSIssued, now, now))
|
||||
pure
|
||||
UserBadgePurchase
|
||||
{ badgePurchaseId,
|
||||
userId,
|
||||
purchaseKey,
|
||||
purchasePrivKey,
|
||||
masterKey,
|
||||
initialBadgeType = badgeType,
|
||||
currentBadgeType = badgeType,
|
||||
paymentId = Just paymentId,
|
||||
status = PSIssued,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
}
|
||||
|
||||
-- | Moves every OTHER 'PSIssued' purchase of the same slot for this user to 'PSSuperseded', so
|
||||
-- the slot has exactly one issued purchase again. The superseded row keeps its unconsumed
|
||||
-- months: purchases are unlinkable, so the service cannot move a balance between them.
|
||||
--
|
||||
-- The slot filter is applied in Haskell rather than as a SQL @IN@ over the slot's badge types:
|
||||
-- @IN ?@ with a list is postgresql-simple's 'Database.PostgreSQL.Simple.In', which
|
||||
-- sqlite-simple has no counterpart for, and a user holds at most a handful of purchases.
|
||||
supersedePurchases :: DB.Connection -> UserId -> Int64 -> BadgeType -> UTCTime -> IO ()
|
||||
supersedePurchases db userId keepPurchaseId badgeType now = do
|
||||
rows <-
|
||||
DB.query
|
||||
db
|
||||
"SELECT badge_purchase_id, current_badge_type FROM badge_purchases WHERE user_id = ? AND status = ? AND badge_purchase_id <> ?"
|
||||
(userId, PSIssued, keepPurchaseId)
|
||||
let slot = badgeSlot badgeType
|
||||
superseded = [pId | (pId :: Int64, bt) <- rows, badgeSlot bt == slot]
|
||||
forM_ superseded $ \pId ->
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE badge_purchases SET status = ?, updated_at = ? WHERE badge_purchase_id = ?"
|
||||
(PSSuperseded, now, pId)
|
||||
|
||||
-- | Points @users.shown_badge_id@ at a purchase. Separate from
|
||||
-- 'Simplex.Chat.Store.Profiles.setUserBadge', which writes the profile's badge columns: this
|
||||
-- one records WHICH purchase the profile's badge came from, which is what
|
||||
-- 'getShownPurchase' reads back to sign the next @issueBadge@.
|
||||
setShownPurchase :: DB.Connection -> UserId -> Int64 -> IO ()
|
||||
setShownPurchase db userId badgePurchaseId =
|
||||
DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (badgePurchaseId, userId)
|
||||
|
||||
purchaseSelectColumns :: Query
|
||||
purchaseSelectColumns =
|
||||
"p.badge_purchase_id, p.user_id, p.purchase_key, p.purchase_priv_key, p.master_key, "
|
||||
<> "p.initial_badge_type, p.current_badge_type, p.payment_id, p.status, p.created_at, p.updated_at"
|
||||
|
||||
type PurchaseRow =
|
||||
(Int64, Maybe UserId, C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519, Binary ByteString)
|
||||
:. (BadgeType, BadgeType, Maybe Text, BadgePurchaseStatus, UTCTime, UTCTime)
|
||||
|
||||
-- | @user_id@ and @purchase_priv_key@ are nullable only because @20260731_user_badges@ adds them
|
||||
-- with @ALTER TABLE@, which cannot add a NOT NULL column without a default. 'createPurchase' is
|
||||
-- the only writer and always sets both, so a NULL is a corrupt row and is named as one rather
|
||||
-- than being turned into a purchase whose key is missing — the private key is the whole point of
|
||||
-- reading this row back.
|
||||
rowToPurchase :: PurchaseRow -> Either StoreError UserBadgePurchase
|
||||
rowToPurchase ((badgePurchaseId, userId_, purchaseKey, purchasePrivKey_, Binary mk) :. (initialBadgeType, currentBadgeType, paymentId, status, createdAt, updatedAt)) =
|
||||
case (userId_, purchasePrivKey_) of
|
||||
(Just userId, Just purchasePrivKey) ->
|
||||
Right
|
||||
UserBadgePurchase
|
||||
{ badgePurchaseId,
|
||||
userId,
|
||||
purchaseKey,
|
||||
purchasePrivKey,
|
||||
masterKey = BadgeMasterKey mk,
|
||||
initialBadgeType,
|
||||
currentBadgeType,
|
||||
paymentId,
|
||||
status,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}
|
||||
_ ->
|
||||
Left . SEInternalError $
|
||||
"badge purchase " <> show badgePurchaseId <> " has no user_id or purchase_priv_key"
|
||||
|
||||
-- | The purchase @users.shown_badge_id@ points at, with its keypair and badge master key —
|
||||
-- what the badge screen renders and what the worker signs @issueBadge@ with. 'Nothing' when the
|
||||
-- user has no badge, which is the ordinary case.
|
||||
getShownPurchase :: DB.Connection -> UserId -> ExceptT StoreError IO (Maybe UserBadgePurchase)
|
||||
getShownPurchase db userId = do
|
||||
rows <-
|
||||
liftIO $
|
||||
DB.query
|
||||
db
|
||||
( "SELECT "
|
||||
<> purchaseSelectColumns
|
||||
<> " FROM badge_purchases p JOIN users u ON u.shown_badge_id = p.badge_purchase_id WHERE u.user_id = ?"
|
||||
)
|
||||
(Only userId)
|
||||
liftEither $ mapM rowToPurchase (listToMaybe rows)
|
||||
|
||||
-- Issuances -------------------------------------------------------------------
|
||||
|
||||
-- | Fields for one @badge_issuances@ row. As on the service side, the period and expiry are
|
||||
-- definite here, matching the NOT NULL columns; 'BadgeIssuance' declares them 'Maybe' only
|
||||
-- because it is also a wire shape.
|
||||
data NewBadgeIssuance = NewBadgeIssuance
|
||||
{ badgePurchaseId :: Int64,
|
||||
badgeType :: BadgeType,
|
||||
periodStart :: UTCTime,
|
||||
periodEnd :: UTCTime,
|
||||
expiry :: UTCTime,
|
||||
-- | The @badge_ledger.entry_id@ this issuance was debited by, when the caller has it.
|
||||
-- Named apart from 'BadgeIssuance'\'s @entryId@ (the same column) so the two do not collide
|
||||
-- as bare selectors under @DuplicateRecordFields@.
|
||||
ledgerEntryId :: Maybe Int64,
|
||||
credential :: BadgeCredential
|
||||
}
|
||||
|
||||
-- | Writes one issuance, minting its @issuance_id@ (the column is @TEXT NOT NULL PRIMARY KEY@
|
||||
-- with no default). One row per period: the worker calls this again for each new period, so a
|
||||
-- purchase accumulates issuances rather than replacing one.
|
||||
--
|
||||
-- The credential is stored as its own JSON encoding, the same one it crosses the wire in, so
|
||||
-- there is no second, database-only codec for it to drift from.
|
||||
createIssuance :: DB.Connection -> NewBadgeIssuance -> UTCTime -> IO BadgeIssuance
|
||||
createIssuance db NewBadgeIssuance {badgePurchaseId, badgeType, periodStart, periodEnd, expiry, ledgerEntryId, credential} now = do
|
||||
issuanceId <- UUID.toText <$> UUID.nextRandom
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO badge_issuances
|
||||
(issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(issuanceId, badgePurchaseId, ledgerEntryId, badgeType, periodStart, periodEnd, expiry, Binary (LB.toStrict (J.encode credential)), now)
|
||||
pure
|
||||
BadgeIssuance
|
||||
{ issuanceId,
|
||||
badgePurchaseId,
|
||||
badgeType,
|
||||
periodStart = Just periodStart,
|
||||
periodEnd = Just periodEnd,
|
||||
expiry = Just expiry,
|
||||
entryId = ledgerEntryId,
|
||||
credential,
|
||||
createdAt = now
|
||||
}
|
||||
|
||||
-- Ledger ----------------------------------------------------------------------
|
||||
|
||||
-- | @entry_type, entry_credit_type, entry_debit_type, payment_id, charge_id, from_purchase_id,
|
||||
-- to_purchase_id, entry_type_unknown, entry_type_value@.
|
||||
--
|
||||
-- The first seven are 'BadgeService.Store.LedgerTypeRow' exactly, spelling for spelling. The
|
||||
-- last two are the client-only fallback columns @20260731_user_badges@ adds: a service ahead of
|
||||
-- this build can send an entry type this build has no constructor for, and the client stores it
|
||||
-- as received so a later version can decode it (docs\/protocol\/badges-rpc.md: "An unknown type
|
||||
-- is stored as received and decoded after an app upgrade"). The service, authoring every entry
|
||||
-- it writes, never needs them and its table does not have them.
|
||||
type LedgerTypeRow = (Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text, Maybe Int64, Maybe Int64, BoolInt, Maybe Text)
|
||||
|
||||
type LedgerCoreRow = (Int64, Text, Int64, Int, Int, UTCTime, BadgeType, Maybe UTCTime, UTCTime, UTCTime)
|
||||
|
||||
ledgerSelectColumns :: Query
|
||||
ledgerSelectColumns =
|
||||
"entry_id, entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_badge_type, was_paused_since, service_created_at, created_at, "
|
||||
<> "entry_type, entry_credit_type, entry_debit_type, payment_id, charge_id, from_purchase_id, to_purchase_id, entry_type_unknown, entry_type_value"
|
||||
|
||||
-- | The wire entry type as the client stores it: the inverse of
|
||||
-- 'BadgeService.Service.statementEntryType'.
|
||||
--
|
||||
-- @payment@ becomes @'CTPayment' 'Nothing'@ and the wire's @invoiceId@ is DROPPED, which is not
|
||||
-- a loss of information the client can hold: @badge_ledger.payment_id@ references @payments@,
|
||||
-- and a code redemption writes no client @payments@ row for the SERVICE's payment — that row
|
||||
-- exists only in the service database, and the wire carries no id for it. Inventing one would
|
||||
-- make the column a dangling reference (plan §9, and §10's cross-database invariant, which
|
||||
-- excludes @payment_id@ for exactly this reason).
|
||||
--
|
||||
-- @charge@, @transferIn@, @upgrade@ and @transferOut@ are refused, matching
|
||||
-- 'BadgeService.Service.statementEntryType', which refuses to put any of them on the wire in the
|
||||
-- first place, so nothing can send one. @charge@'s @chargeId@ is 'Int64' against a TEXT column
|
||||
-- (an unresolved type mismatch, plan §9) and the other three carry a purchase KEY where the
|
||||
-- column holds a purchase id; both would need a coercion this plan has not decided, and
|
||||
-- subscriptions, upgrades and transfers are out of scope (§6).
|
||||
storedEntryType :: StatementEntryType -> Either StoreError LedgerEntryType
|
||||
storedEntryType = \case
|
||||
SECredit creditType -> LECredit <$> case creditType of
|
||||
SCPayment {} -> Right CTPayment {paymentId = Nothing}
|
||||
SCSupport -> Right CTSupport
|
||||
SCOpening -> Right CTOpening
|
||||
SCUnknown {tag, json} -> Right CTUnknown {tag, json}
|
||||
SCCharge {} -> unsupported "credit(charge)" "chargeId is Int64 against the charge_id TEXT column; subscriptions are out of scope"
|
||||
SCTransferIn {} -> unsupported "credit(transferIn)" "carries a purchase key where from_purchase_id holds a purchase id; transfers are out of scope"
|
||||
SEDebit debitType -> LEDebit <$> case debitType of
|
||||
SDRefund -> Right DTRefund
|
||||
SDSupport -> Right DTSupport
|
||||
SDBadge -> Right DTBadge
|
||||
SDLapse -> Right DTLapse
|
||||
SDUnknown {tag, json} -> Right DTUnknown {tag, json}
|
||||
SDUpgrade {} -> unsupported "debit(upgrade)" "carries a purchase key where to_purchase_id holds a purchase id; upgrades are out of scope"
|
||||
SDTransferOut {} -> unsupported "debit(transferOut)" "carries a purchase key where to_purchase_id holds a purchase id; transfers are out of scope"
|
||||
where
|
||||
unsupported what why = Left . SEInternalError $ "cannot store badge ledger " <> what <> ": " <> why
|
||||
|
||||
-- | Known tags are spelled exactly as 'BadgeService.Store.encodeLedgerEntryType' spells them —
|
||||
-- the two stores write the same columns of the same schema, and the service reads back what it
|
||||
-- wrote. An unknown tag keeps its own spelling in @entry_credit_type@\/@entry_debit_type@ and
|
||||
-- its whole object in @entry_type_value@, flagged by @entry_type_unknown@, which is what makes
|
||||
-- 'decodeLedgerEntryType' able to hand it back unchanged after an upgrade.
|
||||
encodeLedgerEntryType :: LedgerEntryType -> Either StoreError LedgerTypeRow
|
||||
encodeLedgerEntryType = \case
|
||||
LECredit creditType -> case creditType of
|
||||
CTPayment {paymentId} -> Right ("credit", Just "payment", Nothing, paymentId, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
CTSupport -> Right ("credit", Just "support", Nothing, Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
CTTransferIn {fromPurchaseId} -> Right ("credit", Just "transfer_in", Nothing, Nothing, Nothing, fromPurchaseId, Nothing, BI False, Nothing)
|
||||
CTOpening -> Right ("credit", Just "opening", Nothing, Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
CTUnknown {tag, json} -> Right ("credit", Just tag, Nothing, Nothing, Nothing, Nothing, Nothing, BI True, Just (encodeUnknown json))
|
||||
CTCharge {} -> Left $ SEInternalError "cannot store badge ledger credit(charge): chargeId is Int64 against the charge_id TEXT column"
|
||||
LEDebit debitType -> case debitType of
|
||||
DTRefund -> Right ("debit", Nothing, Just "refund", Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
DTUpgrade {toPurchaseId} -> Right ("debit", Nothing, Just "upgrade", Nothing, Nothing, Nothing, Just toPurchaseId, BI False, Nothing)
|
||||
DTTransferOut {toPurchaseId} -> Right ("debit", Nothing, Just "transfer_out", Nothing, Nothing, Nothing, Just toPurchaseId, BI False, Nothing)
|
||||
DTSupport -> Right ("debit", Nothing, Just "support", Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
DTBadge -> Right ("debit", Nothing, Just "badge", Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
DTLapse -> Right ("debit", Nothing, Just "lapse", Nothing, Nothing, Nothing, Nothing, BI False, Nothing)
|
||||
DTUnknown {tag, json} -> Right ("debit", Nothing, Just tag, Nothing, Nothing, Nothing, Nothing, BI True, Just (encodeUnknown json))
|
||||
|
||||
-- | @entry_type_value@ holds the entry type's whole JSON object, including its own @type@ key,
|
||||
-- because that object is what 'Simplex.Chat.Badges.Service.SCUnknown' re-encodes verbatim. The
|
||||
-- column is TEXT and aeson emits UTF-8, so it round-trips through 'decodeUnknown'.
|
||||
encodeUnknown :: J.Object -> Text
|
||||
encodeUnknown = decodeUtf8 . LB.toStrict . J.encode . J.Object
|
||||
|
||||
decodeUnknown :: Text -> Maybe J.Object
|
||||
decodeUnknown = J.decodeStrict . encodeUtf8
|
||||
|
||||
-- | Reads back what 'encodeLedgerEntryType' wrote. The unknown flag is checked FIRST: a service
|
||||
-- ahead of this build could use a tag this build later learns, and the flag records what this
|
||||
-- row actually was when it was stored.
|
||||
decodeLedgerEntryType :: LedgerTypeRow -> Either StoreError LedgerEntryType
|
||||
decodeLedgerEntryType row = case row of
|
||||
("credit", Just tag, _, _, _, _, _, BI True, Just v) -> unknownCredit tag v
|
||||
("debit", _, Just tag, _, _, _, _, BI True, Just v) -> unknownDebit tag v
|
||||
-- payment_id is read whether or not it is there: a client-written entry always holds NULL.
|
||||
("credit", Just "payment", _, paymentId, _, _, _, _, _) -> Right $ LECredit CTPayment {paymentId}
|
||||
("credit", Just "support", _, _, _, _, _, _, _) -> Right $ LECredit CTSupport
|
||||
("credit", Just "transfer_in", _, _, _, fromPurchaseId, _, _, _) -> Right $ LECredit CTTransferIn {fromPurchaseId}
|
||||
("credit", Just "opening", _, _, _, _, _, _, _) -> Right $ LECredit CTOpening
|
||||
("debit", _, Just "refund", _, _, _, _, _, _) -> Right $ LEDebit DTRefund
|
||||
("debit", _, Just "upgrade", _, _, _, Just toPurchaseId, _, _) -> Right $ LEDebit DTUpgrade {toPurchaseId}
|
||||
("debit", _, Just "transfer_out", _, _, _, Just toPurchaseId, _, _) -> Right $ LEDebit DTTransferOut {toPurchaseId}
|
||||
("debit", _, Just "support", _, _, _, _, _, _) -> Right $ LEDebit DTSupport
|
||||
("debit", _, Just "badge", _, _, _, _, _, _) -> Right $ LEDebit DTBadge
|
||||
("debit", _, Just "lapse", _, _, _, _, _, _) -> Right $ LEDebit DTLapse
|
||||
(entryType, creditType, debitType, _, _, _, _, BI unknown, _) ->
|
||||
Left . SEInternalError $
|
||||
"malformed badge ledger entry type row: " <> show (entryType, creditType, debitType, unknown)
|
||||
where
|
||||
unknownCredit tag v = maybe (badValue tag) (\json -> Right $ LECredit CTUnknown {tag, json}) (decodeUnknown v)
|
||||
unknownDebit tag v = maybe (badValue tag) (\json -> Right $ LEDebit DTUnknown {tag, json}) (decodeUnknown v)
|
||||
badValue tag = Left . SEInternalError $ "badge ledger entry_type_value is not a JSON object, tag: " <> T.unpack tag
|
||||
|
||||
rowToLedgerEntry :: (LedgerCoreRow :. LedgerTypeRow) -> Either StoreError BadgeLedgerEntry
|
||||
rowToLedgerEntry ((entryId, entryUuid, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType, wasPausedSince, serviceCreatedAt, createdAt) :. typeRow) = do
|
||||
entryType <- decodeLedgerEntryType typeRow
|
||||
Right BadgeLedgerEntry {entryId, entryUuid, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType, wasPausedSince, serviceCreatedAt, createdAt, entryType}
|
||||
|
||||
-- | The purchase's last entry: its @balanceMonths@ and @balanceStartTs@ are the balance the
|
||||
-- client believes it holds, which is what the worker asserts to the service and what the badge
|
||||
-- screen's paid-through date is computed from. Ordered by @entry_id@, which is the order
|
||||
-- 'insertLedgerEntries' wrote the statement in.
|
||||
getLastBadgeLedgerEntry :: DB.Connection -> Int64 -> ExceptT StoreError IO (Maybe BadgeLedgerEntry)
|
||||
getLastBadgeLedgerEntry db badgePurchaseId = do
|
||||
rows <-
|
||||
liftIO $
|
||||
DB.query
|
||||
db
|
||||
("SELECT " <> ledgerSelectColumns <> " FROM badge_ledger WHERE badge_purchase_id = ? ORDER BY entry_id DESC LIMIT 1")
|
||||
(Only badgePurchaseId)
|
||||
liftEither $ mapM rowToLedgerEntry (listToMaybe rows)
|
||||
|
||||
-- | Copies a statement's entries into the client's replica of the service's ledger.
|
||||
--
|
||||
-- __Order is carried only by array position.__ Every entry the service writes for one command
|
||||
-- gets an identical @createdAt@, and @serviceCreatedAt@ is not on the wire at all, so
|
||||
-- @statement.entries@ is inserted in the order given and @entry_id@ (the local autoincrement) is
|
||||
-- the only thing that records it. Sorting by any timestamp would silently scramble the ledger.
|
||||
--
|
||||
-- __@previousEntryId@ decides REPLACE against APPEND.__ Present, it names an entry the client
|
||||
-- already holds and the entries attach after it; absent with entries present, the entries
|
||||
-- "attach to nothing" (docs\/protocol\/badges-rpc.md) — they are the COMPLETE ledger for this
|
||||
-- purchase, and anything the client holds that the statement does not carry is stale and is
|
||||
-- deleted. Appending on an absent @previousEntryId@ would leave a client that had healed or
|
||||
-- reset holding rows the service no longer has. A statement with no entries at all changes
|
||||
-- nothing either way: it attaches nothing and is not a claim that the ledger is empty.
|
||||
--
|
||||
-- REPLACE is expressed as "delete what the statement does not carry, then insert what it does",
|
||||
-- not as a wipe-and-rewrite, so rows the service is merely re-sending keep their local
|
||||
-- @entry_id@ — @badge_issuances.entry_id@ references them. When the surviving rows are not a
|
||||
-- prefix of the delivered entries (a ledger the service rewrote rather than extended) they are
|
||||
-- all dropped instead, so the re-inserted entries cannot end up out of order.
|
||||
--
|
||||
-- __Re-delivery is normal, not exceptional.__ @purchaseBadge@ and @getBadgeCatalog@ return the
|
||||
-- complete history every time; only @issueBadge@ honours a cursor. Insertion is therefore
|
||||
-- @ON CONFLICT (entry_uuid) DO NOTHING@ against @idx_badge_ledger_uuid@ — a second delivery of
|
||||
-- the same statement writes nothing and changes nothing, rather than merely not crashing.
|
||||
insertLedgerEntries :: DB.Connection -> Int64 -> BadgeStatement -> UTCTime -> ExceptT StoreError IO ()
|
||||
insertLedgerEntries db badgePurchaseId BadgeStatement {entries, previousEntryId} now = do
|
||||
rows <- liftEither $ mapM entryRow entries
|
||||
liftIO $ do
|
||||
case previousEntryId of
|
||||
Just _ -> pure ()
|
||||
Nothing -> unless (null entries) $ dropStaleEntries (map statementEntryId entries)
|
||||
forM_ rows $ \row ->
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO badge_ledger
|
||||
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_badge_type, was_paused_since, service_created_at, created_at,
|
||||
entry_type, entry_credit_type, entry_debit_type, payment_id, charge_id, from_purchase_id, to_purchase_id, entry_type_unknown, entry_type_value)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (entry_uuid) DO NOTHING
|
||||
|]
|
||||
row
|
||||
where
|
||||
statementEntryId StatementEntry {entryId} = entryId
|
||||
-- service_created_at is the service's own clock, which the wire reports as the entry's
|
||||
-- createdAt; created_at is this client's, i.e. when the row was replicated. Keeping them
|
||||
-- apart is what lets the two databases be compared on the service's timestamp.
|
||||
entryRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType, wasPausedSince, createdAt, entryType} = do
|
||||
typeRow <- encodeLedgerEntryType =<< storedEntryType entryType
|
||||
Right $
|
||||
(entryId, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType, wasPausedSince, createdAt, now)
|
||||
:. typeRow
|
||||
dropStaleEntries uuids = do
|
||||
held <-
|
||||
map fromOnly
|
||||
<$> DB.query
|
||||
db
|
||||
"SELECT entry_uuid FROM badge_ledger WHERE badge_purchase_id = ? ORDER BY entry_id ASC"
|
||||
(Only badgePurchaseId)
|
||||
let kept = filter (`elem` uuids) held
|
||||
stale
|
||||
| kept `isPrefixOf` uuids = filter (`notElem` uuids) held
|
||||
| otherwise = held
|
||||
forM_ stale $ \entryUuid ->
|
||||
DB.execute db "DELETE FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (badgePurchaseId, entryUuid)
|
||||
Reference in New Issue
Block a user