mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +00:00
* badges: webapp (#7433) * badges: service migrations, store and catalog * badges: BTCPay provider and settlement poller * badges: web listener and /api endpoints * web: checkout single-page app * badges: tests and BTCPay fixtures * badges: README and ini reference * badges: fix hex16 build on GHC 8.10.7 * badges: Stripe card lane * badges: fix Stripe card checkout, add theming * badges: add a discount row to the order summary * badges: site navbar, embedding, theme, Forget move * badges: use SB code prefix in web checkout * badges: rename sxb app namespace to sb * badges: embed checkout nav via site; keep original app navbar * badges: post iframe height, apply site background when embedded * badges: embed dark surfaces, steadier iframe height * badges: hide app footer when embedded * badges: size embedded body to content, not viewport * badges: declare color-scheme to stop reload flash * badges: fade shell in on load, no reload blank * badges: prerender app shell into index.html * badges: pre-paint theme, hide shell on deep reload * badges: logo returns to landing client-side * badges: embedded wizard back, buy-a-code, resume * badges: signal app-managed screens, resume across reload * badges: rebuild wizard history on deep load so Back walks it * badges: carry welcome-page height as the iframe floor * badges: keep selection on Buy a code; rename to Your codes * badges: read web shell as UTF-8, not locale * badges: resume the exact paid order after Stripe card redirect * badges: move docker deploy under scripts * badges: add serve_webapp toggle and webapp export * badges: wire split webapp deploy in docker config * badges: quiet agent logs by default * badges: resume card redirect in the embedded frame * badges: migrate Stripe adapter to PaymentIntents * badges: correct Stripe restricted key scopes in ini example * badges: card via Payment Element and PaymentIntents * badges: fix stale Checkout Session wording in Stripe adapter * badges: fix stale CheckoutActions reference in card comment * badges: order shell stylesheet before bootstrap script * badges: remove development card stand-in * badges: theme the Stripe card form with the site palette * badges: exclude web from the Haskell build stage * badges: unify invoice cancel and mark canceled * badges: default log level to info * badges: unify closed-invoice buy-again button * badges: mute agent connection logs at info level * badges: show purchase time in local timezone in Your codes * badges: log service events on own channel, quiet agent * badges: fold service migrations into one baseline * badges: run compose on postgres over host network * badges: use high-res hero art * badges: add web CI to catch stale builds * badges: rebuild web shell from committed source * badges: normalize invoice-code link and columns * badges: drop unused columns, rename index * badges: note deferred receipt_hash in migrations * badges: apply code-review fixes * badges: reduce comments across service and web --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: improve web page (#7546) * badges: improve web page * improve layout * improve layout * fix * small changes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: read one issuer key from the ini * badges: move and group the service tests * badges: service fixes (#7567) * badges: match the redeem error wording in tests * badges: drop unused imports in the bot tests * badges: cancel Stripe orders when they expire * badges: correct the Stripe config and docs * badges: refuse to revoke a redeemed code * badges: make the fake Stripe cancel like Stripe * badges: limit replayed webhook deliveries --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
277 lines
11 KiB
Haskell
277 lines
11 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE QuasiQuotes #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module BadgeService.Store
|
|
( IssuedCode (..),
|
|
CodeRedemption (..),
|
|
RedeemedCode (..),
|
|
NewCodePurchase (..),
|
|
ServicePurchase (..),
|
|
getBadgeCode,
|
|
purchaseKeyExists,
|
|
getPurchaseByKey,
|
|
getLedgerTip,
|
|
getLedgerEntryId,
|
|
getLedgerEntries,
|
|
getCurrentIssuance,
|
|
appendLedgerPlan,
|
|
createCodePurchase,
|
|
insertBadgeCode,
|
|
RevokeResult (..),
|
|
revokeCode,
|
|
)
|
|
where
|
|
|
|
import BadgeService.Store.Invoices (executeChanging)
|
|
import qualified Data.Aeson as J
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
|
import Data.Int (Int64)
|
|
import Data.Text (Text)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType)
|
|
import Simplex.Chat.Badges.Ledger
|
|
import Simplex.Chat.Badges.Service (StatementEntry (..))
|
|
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..))
|
|
import Simplex.Chat.Store.Shared (insertedRowId)
|
|
import Simplex.Messaging.Agent.Store.DB (Binary (..))
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow')
|
|
|
|
#if defined(dbPostgres)
|
|
import Database.PostgreSQL.Simple (Only (..), (:.) (..))
|
|
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
|
#else
|
|
import Database.SQLite.Simple (Only (..), (:.) (..))
|
|
import Database.SQLite.Simple.QQ (sql)
|
|
#endif
|
|
|
|
data IssuedCode = IssuedCode
|
|
{ badgeCodeId :: Int64,
|
|
badgeType :: BadgeType,
|
|
months :: Int,
|
|
paymentStatus :: BadgeCodePaymentStatus,
|
|
revokedAt :: Maybe UTCTime,
|
|
expiresAt :: Maybe UTCTime,
|
|
redemption :: CodeRedemption
|
|
}
|
|
|
|
data CodeRedemption
|
|
= CodeUnredeemed
|
|
| CodeRedeemed RedeemedCode
|
|
| CodeRedeemedUnreadable
|
|
|
|
data RedeemedCode = RedeemedCode
|
|
{ badgePurchaseId :: Int64,
|
|
purchaseKey :: C.PublicKeyEd25519,
|
|
credential :: BadgeCredential
|
|
}
|
|
|
|
data NewCodePurchase = NewCodePurchase
|
|
{ badgeCodeId :: Int64,
|
|
purchaseKey :: C.PublicKeyEd25519,
|
|
masterKey :: BadgeMasterKey,
|
|
badgeType :: BadgeType
|
|
}
|
|
|
|
data ServicePurchase = ServicePurchase
|
|
{ badgePurchaseId :: Int64,
|
|
masterKey :: BadgeMasterKey,
|
|
badgeType :: BadgeType
|
|
}
|
|
|
|
getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode)
|
|
getBadgeCode db codeHash =
|
|
maybeFirstRow toCode $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT c.badge_code_id, c.badge_type, c.months, c.code_payment_status, c.revoked_at,
|
|
c.expires_at, p.badge_purchase_id, p.purchase_key, i.credential
|
|
FROM sx_badge_service_badge_codes c
|
|
LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id
|
|
LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id
|
|
WHERE c.code_hash = ?
|
|
ORDER BY i.period_end DESC
|
|
LIMIT 1
|
|
|]
|
|
(Only (Binary codeHash))
|
|
where
|
|
toCode (badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, purchaseId_, purchaseKey_, credential_) =
|
|
IssuedCode {badgeCodeId, badgeType, months, paymentStatus, revokedAt, expiresAt, redemption = codeRedemption purchaseId_ purchaseKey_ credential_}
|
|
codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of
|
|
(Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of
|
|
Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential}
|
|
Nothing -> CodeRedeemedUnreadable
|
|
_ -> CodeUnredeemed
|
|
decodeCredential (Binary bs) = J.decodeStrict' bs
|
|
|
|
purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool
|
|
purchaseKeyExists db key =
|
|
maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $
|
|
DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key)
|
|
|
|
getPurchaseByKey :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe ServicePurchase)
|
|
getPurchaseByKey db key =
|
|
maybeFirstRow toPurchase $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT badge_purchase_id, master_key, current_badge_type
|
|
FROM sx_badge_service_badge_purchases
|
|
WHERE purchase_key = ?
|
|
|]
|
|
(Only key)
|
|
where
|
|
toPurchase (badgePurchaseId, Binary mk, badgeType) =
|
|
ServicePurchase {badgePurchaseId, masterKey = BadgeMasterKey mk, badgeType}
|
|
|
|
getLedgerTip :: DB.Connection -> Int64 -> IO (Maybe StatementEntry)
|
|
getLedgerTip db purchaseId =
|
|
maybeFirstRow' Nothing toEntry $
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
|
|
entry_type, entry_credit_type, entry_debit_type, service_created_at
|
|
FROM sx_badge_service_badge_ledger
|
|
WHERE badge_purchase_id = ?
|
|
ORDER BY entry_id DESC
|
|
LIMIT 1
|
|
|]
|
|
(Only purchaseId)
|
|
|
|
getLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64)
|
|
getLedgerEntryId db purchaseId entryUuid =
|
|
maybeFirstRow fromOnly $
|
|
DB.query
|
|
db
|
|
"SELECT entry_id FROM sx_badge_service_badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?"
|
|
(purchaseId, entryUuid)
|
|
|
|
-- | 0 returns the whole ledger, as entry_id starts at 1.
|
|
getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry])
|
|
getLedgerEntries db purchaseId afterEntryId =
|
|
mapM toEntry
|
|
<$> DB.query
|
|
db
|
|
[sql|
|
|
SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type,
|
|
entry_type, entry_credit_type, entry_debit_type, service_created_at
|
|
FROM sx_badge_service_badge_ledger
|
|
WHERE badge_purchase_id = ? AND entry_id > ?
|
|
ORDER BY entry_id
|
|
|]
|
|
(purchaseId, afterEntryId)
|
|
|
|
toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry
|
|
toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) =
|
|
(\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType})
|
|
<$> entryTypeFromColumns entryType_ credit_ debit_
|
|
|
|
getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential)
|
|
getCurrentIssuance db purchaseId now = do
|
|
rs <-
|
|
DB.query
|
|
db
|
|
[sql|
|
|
SELECT credential FROM sx_badge_service_badge_issuances
|
|
WHERE badge_purchase_id = ? AND period_end > ?
|
|
ORDER BY period_end DESC
|
|
LIMIT 1
|
|
|]
|
|
(purchaseId, now)
|
|
pure $ case rs of
|
|
[Only (Binary bs)] -> J.decodeStrict' bs
|
|
_ -> Nothing
|
|
|
|
-- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today.
|
|
appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO ()
|
|
appendLedgerPlan db purchaseId rows issuance_ = do
|
|
mapM_ appendRow rows
|
|
case issuance_ of
|
|
Nothing -> pure ()
|
|
Just (previous, issued@StatementEntry {entryId, balanceStartTs = periodEnd, balanceBadgeType, createdAt}, credential) -> do
|
|
rowId <- appendRow issued
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_badge_issuances
|
|
(issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?)
|
|
|]
|
|
( (entryId, purchaseId, rowId, balanceBadgeType)
|
|
:. (balanceStartTs previous, periodEnd, endOfMondayAfter periodEnd, Binary (LB.toStrict $ J.encode credential), createdAt)
|
|
)
|
|
where
|
|
appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do
|
|
let (entryTypeT, creditType, debitType) = entryTypeColumns entryType
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_badge_ledger
|
|
(entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts,
|
|
balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
|]
|
|
((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType))
|
|
insertedRowId db
|
|
|
|
-- redeemed_at is stamped here, so this must run in the same transaction as the credential rows.
|
|
-- Mark the code as redeemed before adding the purchase. On Postgres, a revoke or redemption running
|
|
-- at the same time then waits, sees the code is taken, and fails.
|
|
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe Int64)
|
|
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do
|
|
claimed <-
|
|
executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
|
|
(now, badgeCodeId)
|
|
if claimed == 0
|
|
then pure Nothing
|
|
else do
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_badge_purchases
|
|
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?)
|
|
|]
|
|
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
|
|
Just <$> insertedRowId db
|
|
|
|
data RevokeResult = Revoked | AlreadyRevoked | AlreadyRedeemed | NoSuchCode
|
|
deriving (Eq, Show)
|
|
|
|
-- | A code that was already redeemed can't be revoked, because its badge was already given out.
|
|
revokeCode :: DB.Connection -> ByteString -> UTCTime -> IO RevokeResult
|
|
revokeCode db codeHash now = do
|
|
revoked <-
|
|
executeChanging
|
|
db
|
|
"UPDATE sx_badge_service_badge_codes SET revoked_at = ? WHERE code_hash = ? AND revoked_at IS NULL AND redeemed_at IS NULL"
|
|
(now, Binary codeHash)
|
|
if revoked > 0
|
|
then pure Revoked
|
|
else
|
|
maybeFirstRow' NoSuchCode refusal $
|
|
DB.query db "SELECT revoked_at FROM sx_badge_service_badge_codes WHERE code_hash = ?" (Only (Binary codeHash))
|
|
where
|
|
refusal :: Only (Maybe UTCTime) -> RevokeResult
|
|
refusal (Only revokedAt) = maybe AlreadyRedeemed (const AlreadyRevoked) revokedAt
|
|
|
|
insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO ()
|
|
insertBadgeCode db codeHash badgeType months paymentStatus now =
|
|
DB.execute
|
|
db
|
|
[sql|
|
|
INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at)
|
|
VALUES (?,?,?,?,?)
|
|
|]
|
|
(Binary codeHash, badgeType, months, paymentStatus, now)
|