diff --git a/common/types.ts b/common/types.ts index fc42fad..10ed9eb 100644 --- a/common/types.ts +++ b/common/types.ts @@ -81,21 +81,29 @@ export type Cart = { export type IdentifiableCart = Cart & { userId: string }; -export type Transaction = { +export type Transaction = BitsTransaction | CreditTransaction; + +export type TransactionBase = { token: string; // JWT with TransactionToken (given by EBS on prepurchase) clientSession: string; // same session as in Cart + type: "bits" | "credit"; +}; +export type BitsTransaction = TransactionBase & { + type: "bits"; receipt: string; // JWT with BitsTransactionPayload (coming from Twitch) }; +export type CreditTransaction = TransactionBase & { type: "credit" }; + export type DecodedTransaction = { token: TransactionTokenPayload; - receipt: BitsTransactionPayload; -}; +} & ({ type: "credit" } | { type: "bits"; receipt: BitsTransactionPayload }); export type TransactionToken = { id: string; time: number; // Unix millis user: { id: string; // user channel id + credit: number; }; product: { sku: string; @@ -148,6 +156,7 @@ export type User = { login?: string; displayName?: string; banned: boolean; + credit: number; }; export type OrderState = @@ -155,6 +164,7 @@ export type OrderState = | "prepurchase" | "cancelled" | "paid" // waiting for game + | "denied" // routine rejection e.g. precondition failed | "failed" // game failed/timed out | "succeeded"; diff --git a/ebs/src/modules/game/messages.game.ts b/ebs/src/modules/game/messages.game.ts index e900d0e..6229dfe 100644 --- a/ebs/src/modules/game/messages.game.ts +++ b/ebs/src/modules/game/messages.game.ts @@ -22,9 +22,11 @@ export type LogMessage = GameMessageBase & { message: string; }; +export type ResultKind = "success" | "error" | "deny"; + export type ResultMessage = GameMessageBase & { messageType: MessageType.Result; - success: boolean; + status: ResultKind; message?: string; }; diff --git a/ebs/src/modules/orders/endpoints/public.ts b/ebs/src/modules/orders/endpoints/public.ts index 76a4df0..52bc661 100644 --- a/ebs/src/modules/orders/endpoints/public.ts +++ b/ebs/src/modules/orders/endpoints/public.ts @@ -138,28 +138,30 @@ app.post( return; } - const bitsTransaction = decoded.receipt.data.transactionId; - if (usedBitsTransactionIds.has(bitsTransaction)) { - // happens if there are X extension tabs that are all open on the twitch bits modal - // twitch broadcasts onTransactionComplete to all of them and the client ends up - // sending X requests for each completed transaction (where all but 1 will obviously be duplicates) - // we don't want to auto-ban people just for having multiple tabs open - // but it's still obviously not ideal behaviour - if (order.cart.clientSession === transaction.clientSession) { - // if it's not coming from a different tab, you're obviously trying to replay - logMessage.content = { - order: order.id, - bitsTransaction: decoded.receipt.data, - }; - logMessage.header = "Transaction replay"; - sendToLogger(logContext).then(); + if (decoded.type === "bits") { + const bitsTransaction = decoded.receipt.data.transactionId; + if (usedBitsTransactionIds.has(bitsTransaction)) { + // happens if there are X extension tabs that are all open on the twitch bits modal + // twitch broadcasts onTransactionComplete to all of them and the client ends up + // sending X requests for each completed transaction (where all but 1 will obviously be duplicates) + // we don't want to auto-ban people just for having multiple tabs open + // but it's still obviously not ideal behaviour + if (order.cart.clientSession === transaction.clientSession) { + // if it's not coming from a different tab, you're obviously trying to replay + logMessage.content = { + order: order.id, + bitsTransaction: decoded.receipt.data, + }; + logMessage.header = "Transaction replay"; + sendToLogger(logContext).then(); + } + // unfortunately, in this case any other tab(s) awaiting twitchUseBits will still lose their purchase + // so we do our best to not allow multiple active prepurchases in the first place + res.status(401).send("Invalid transaction"); + return; } - // unfortunately, in this case any other tab(s) awaiting twitchUseBits will still lose their purchase - // so we do our best to not allow multiple active prepurchases in the first place - res.status(401).send("Invalid transaction"); - return; + usedBitsTransactionIds.add(bitsTransaction); } - usedBitsTransactionIds.add(bitsTransaction); if (order.userId != req.user.id) { // paying for somebody else, how generous diff --git a/ebs/src/modules/orders/transaction.ts b/ebs/src/modules/orders/transaction.ts index aa0a668..b0ec127 100644 --- a/ebs/src/modules/orders/transaction.ts +++ b/ebs/src/modules/orders/transaction.ts @@ -1,7 +1,8 @@ import { Order, Transaction, User, OrderState, TransactionToken, TransactionTokenPayload, DecodedTransaction, BitsTransactionPayload } from "common/types"; import { verifyJWT, parseJWT } from "../../util/jwt"; -import { getOrder, saveOrder } from "../../util/db"; -import { ResultMessage } from "../game/messages.game"; +import { addCredit, getOrAddUser, getOrder, saveOrder } from "../../util/db"; +import { ResultKind, ResultMessage } from "../game/messages.game"; +import { sendToLogger } from "../../util/logger"; import { HttpResult } from "../../types"; export const jwtExpirySeconds = 60; @@ -9,33 +10,49 @@ const jwtExpiryToleranceSeconds = 15; const defaultResult: HttpResult = { status: 403, message: "Invalid transaction" }; export function decodeJWTPayloads(transaction: Transaction): HttpResult | DecodedTransaction { + if (transaction.type !== "bits" && transaction.type !== "credit") { + return { ...defaultResult, logHeaderOverride: "Invalid type" }; + } if (!transaction.token || !verifyJWT(transaction.token)) { return { ...defaultResult, logHeaderOverride: "Invalid token" }; } const token = parseJWT(transaction.token) as TransactionTokenPayload; - if (!transaction.receipt || !verifyJWT(transaction.receipt)) { - return { ...defaultResult, logHeaderOverride: "Invalid receipt" }; + if (transaction.type === "bits") { + if (!transaction.receipt || !verifyJWT(transaction.receipt)) { + return { ...defaultResult, logHeaderOverride: "Invalid receipt" }; + } + return { + type: "bits", + token, + receipt: parseJWT(transaction.receipt) as BitsTransactionPayload, + }; } return { + type: "credit", token, - receipt: parseJWT(transaction.receipt) as BitsTransactionPayload, }; } export function verifyTransaction(decoded: DecodedTransaction): HttpResult | TransactionToken { const token = decoded.token; - // we don't care if our token JWT expired - // because if the bits t/a is valid, the person paid and we have to honour it - const receipt = decoded.receipt; - if (receipt.topic != "bits_transaction_receipt") { - // e.g. someone trying to put a token JWT in the receipt field - return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; - } - if (receipt.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { - // status 403 and not 400 because bits JWTs have an expiry of 1 hour - // if you're sending a transaction 1 hour after it happened... you're sus - return { ...defaultResult, logHeaderOverride: "Bits receipt expired" }; + if (decoded.type === "bits") { + // for bits purchases, we don't care if our token JWT expired + // because if the bits t/a is valid, the person paid and we have to honour it + const receipt = decoded.receipt; + if (receipt.topic != "bits_transaction_receipt") { + // e.g. someone trying to put a token JWT in the receipt field + return { ...defaultResult, logHeaderOverride: "Invalid receipt topic" }; + } + if (receipt.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { + // status 403 and not 400 because bits JWTs have an expiry of 1 hour + // if you're sending a transaction 1 hour after it happened... you're sus + return { ...defaultResult, logHeaderOverride: "Bits receipt expired" }; + } + } else if (decoded.type === "credit") { + if (token.exp < Date.now() / 1000 - jwtExpiryToleranceSeconds) { + return { ...defaultResult, status: 400, message: "Transaction expired, try again", logHeaderOverride: "Credit receipt expired" }; + } } return token.data; @@ -68,8 +85,26 @@ export async function getAndCheckOrder(transaction: Transaction, decoded: Decode }; } - // we verified the receipt JWT earlier (in verifyTransaction) - order.receipt = transaction.receipt; + if (transaction.type === "credit") { + const sku = order.cart.sku; + const cost = getBitsPrice(sku); + if (!isFinite(cost) || cost <= 0) { + return { status: 500, message: "Internal configuration error", logHeaderOverride: "Bad SKU", logContents: { order: order.id, sku } }; + } + if (user.credit < cost) { + return { + status: 409, + message: "Insufficient credit", + logContents: { user: user.id, order: order.id, cost, credit: user.credit }, + }; + } + user = await addCredit(user, -cost); + console.log(`Debited ${user.login ?? user.id} (${user.credit + cost} - ${cost} = ${user.credit})`); + order.receipt = `credit (${user.credit + cost} - ${cost} = ${user.credit})`; + } else { + // for bits transactions, we verified the receipt JWT earlier (in verifyTransaction) + order.receipt = transaction.receipt; + } order.state = "paid"; await saveOrder(order); @@ -77,13 +112,19 @@ export async function getAndCheckOrder(transaction: Transaction, decoded: Decode return order; } +const orderStateMap: { [k in ResultKind]: OrderState } = { + success: "succeeded", + error: "failed", + deny: "denied", +}; + export async function processRedeemResult(order: Order, result: ResultMessage): Promise { - order.state = result.success ? "succeeded" : "failed"; + order.state = orderStateMap[result.status]; order.result = result.message; await saveOrder(order); let msg = result.message; const res = { logContents: { order: order.id, cart: order.cart } }; - if (result.success) { + if (result.status === "success") { console.log(`[${result.guid}] Redeem succeeded: ${JSON.stringify(result)}`); msg = "Your transaction was successful! Your redeem will appear on stream soon."; if (result.message) { @@ -91,9 +132,38 @@ export async function processRedeemResult(order: Order, result: ResultMessage): } return { status: 200, message: msg, logHeaderOverride: "Redeem succeeded", ...res }; } else { - console.error(`[${result.guid}] Redeem failed: ${JSON.stringify(result)}`); - msg ??= "Redeem failed."; - return { status: 500, message: msg, logHeaderOverride: "Redeem failed", ...res }; + await refund(order); + let status: number; + let header: string; + if (result.status === "deny") { + status = 400; + msg ??= "The game is not ready to process this redeem."; + header = "Redeem denied"; + } else { + status = 500; + msg ??= "Redeem failed."; + header = "Redeem failed"; + } + console.error(`[${result.guid}] ${header}: ${JSON.stringify(result)}`); + return { status: status, message: msg, logHeaderOverride: header, ...res }; + } +} + +export async function refund(order: Order) { + try { + let user = await getOrAddUser(order.userId); + const cost = parseInt(order.cart.sku.substring(4)); + user = await addCredit(user, cost); + console.log(`Refunded ${user.login ?? user.id} (${user.credit - cost} + ${cost} = ${user.credit})`); + } catch (e) { + console.error(`Could not refund order ${order.id}`); + console.error(e); + sendToLogger({ + transactionToken: order.id, + userIdInsecure: order.userId, + important: true, + fields: [{ header: "Failed to refund", content: { order: order.id, error: e } }], + }); } } @@ -109,6 +179,7 @@ export function makeTransactionToken(order: Order, user: User): TransactionToken time: Date.now(), user: { id: user.id, + credit: user.credit, }, product: { sku, cost }, }; diff --git a/ebs/src/modules/user/endpoints.ts b/ebs/src/modules/user/endpoints.ts index 46ecc1b..1ffe336 100644 --- a/ebs/src/modules/user/endpoints.ts +++ b/ebs/src/modules/user/endpoints.ts @@ -1,5 +1,5 @@ import { app } from "../.."; -import { updateUserTwitchInfo, lookupUser } from "../../util/db"; +import { updateUserTwitchInfo, lookupUser, addCredit } from "../../util/db"; import { asyncCatch } from "../../util/middleware"; import { setUserBanned, setUserSession } from "."; @@ -12,7 +12,7 @@ app.post( updateUserTwitchInfo(req.user).then(); - res.sendStatus(200); + res.status(200).send({ credit: req.user.credit }); }) ); @@ -52,3 +52,25 @@ app.delete( res.status(200).json(user); }) ); + +app.post( + "/private/user/:idOrName/addCredit", + asyncCatch(async (req, res) => { + let user = await lookupUser(req.params["idOrName"]); + if (!user) { + res.sendStatus(404); + return; + } + + const amt = parseInt(req.query["amount"] as string); + if (!isFinite(amt)) { + res.sendStatus(400); + return; + } + + user = await addCredit(user, amt); + console.log(`[Private API] Granted ${amt} credits to ${user.login ?? user.id} (now ${user.credit})`); + res.status(200).json(user); + return; + }) +); diff --git a/ebs/src/util/db.ts b/ebs/src/util/db.ts index 7cfdd2c..d542656 100644 --- a/ebs/src/util/db.ts +++ b/ebs/src/util/db.ts @@ -109,12 +109,13 @@ async function createUser(id: string): Promise { const user: User = { id, banned: false, + credit: 0, }; try { await db.query( ` - INSERT INTO users (id, login, displayName, banned) - VALUES (:id, :login, :displayName, :banned)`, + INSERT INTO users (id, login, displayName, banned, credit) + VALUES (:id, :login, :displayName, :banned, :credit)`, user ); } catch (e: any) { @@ -130,7 +131,10 @@ export async function saveUser(user: User) { await db.query( ` UPDATE users - SET login = :login, displayName = :displayName, banned = :banned + SET login = :login, + displayName = :displayName, + banned = :banned, + credit = :credit WHERE id = :id`, { ...user } ); @@ -167,3 +171,16 @@ export async function updateUserTwitchInfo(user: User): Promise { } return user; } + +export async function addCredit(user: User, amount: number): Promise { + try { + await db.query(`CALL addCredit(:userId, :amount, @credit);`, { userId: user.id, amount }); + const [rows] = (await db.query("SELECT @credit")) as [RowDataPacket[], any]; + user.credit = rows[0]["@credit"]; + return user; + } catch (e: any) { + console.error("Database query failed (addCredit)"); + console.error(e); + throw e; + } +} diff --git a/frontend/www/css/base.css b/frontend/www/css/base.css index 7661282..53bbb5e 100644 --- a/frontend/www/css/base.css +++ b/frontend/www/css/base.css @@ -84,4 +84,8 @@ select { border: 1px solid #ffffff1A; background-color: #333; color: #eee; -} \ No newline at end of file +} + +.sat-10 { + filter: saturate(10%); +} diff --git a/frontend/www/css/buttons.css b/frontend/www/css/buttons.css index 175e883..ef3e430 100644 --- a/frontend/www/css/buttons.css +++ b/frontend/www/css/buttons.css @@ -46,6 +46,15 @@ .btn-primary:focus { box-shadow: 0 0 0 4px rgba(var(--primary-color), 0.5); } +.btn-secondary { + background-color: rgba(var(--secondary-color), 0.75); + border: 1px solid #ffffff26; + color: #fff; +} + +.btn-secondary:focus { + box-shadow: 0 0 0 4px rgba(var(--secondary-color), 0.5); +} .btn-success { background-color: rgba(var(--success-color), 0.75); diff --git a/frontend/www/css/config.css b/frontend/www/css/config.css index eae3041..4679dd0 100644 --- a/frontend/www/css/config.css +++ b/frontend/www/css/config.css @@ -1,5 +1,6 @@ :root { --primary-color: 203, 4, 165; + --secondary-color: 37, 35, 38; --success-color: 2, 109, 58; --danger-color: 153, 14, 6; --redeem-card-border-radius: 10px; diff --git a/frontend/www/css/modals.css b/frontend/www/css/modals.css index b424035..735d649 100644 --- a/frontend/www/css/modals.css +++ b/frontend/www/css/modals.css @@ -169,6 +169,15 @@ form label:not([aria-required]) { margin-right: 6px; } +.bits-inline { + width: 20px; + height: 20px; + + object-fit: contain; + margin-bottom: -3px; + margin-inline: 2px; +} + .modal-overlay { z-index: 10000; @@ -272,4 +281,4 @@ form label:not([aria-required]) { .modal-descriptors { align-items: center; } -} \ No newline at end of file +} diff --git a/frontend/www/css/redeems.css b/frontend/www/css/redeems.css index 0d2105b..def029d 100644 --- a/frontend/www/css/redeems.css +++ b/frontend/www/css/redeems.css @@ -155,4 +155,19 @@ .redeemable-item-price { color: #333; text-shadow: 0 0 3px #333; -} \ No newline at end of file +} + +.top-status-bar { + position: sticky; + top: 0; + height: 1.5rem; + align-items: center; + background: #333; + z-index: 10; + transition: all 0.2s; +} + +.top-status-bar .top-status-bar-left { + padding-inline: 0.5rem; + display: flex; +} diff --git a/frontend/www/html/index.html b/frontend/www/html/index.html index 9a253e3..79067c3 100644 --- a/frontend/www/html/index.html +++ b/frontend/www/html/index.html @@ -90,9 +90,35 @@ + + + +
diff --git a/frontend/www/src/modules/auth.ts b/frontend/www/src/modules/auth.ts index eb76597..167230a 100644 --- a/frontend/www/src/modules/auth.ts +++ b/frontend/www/src/modules/auth.ts @@ -2,7 +2,7 @@ import { ebsFetch } from "../util/ebs"; import { renderRedeemButtons } from "./redeems"; import { refreshConfig, setConfig } from "../util/config"; import { onTwitchAuth, twitchAuth } from "../util/twitch"; -import { clientSession } from "./transaction"; +import { clientSession, setClientsideBalance } from "./transaction"; const $loginPopup = document.getElementById("onboarding")!; const $loginButton = document.getElementById("twitch-login")!; @@ -29,6 +29,10 @@ function onAuth(auth: Twitch.ext.Authorized) { if (res.status === 403) { setBanned(true); } + res.json().then((resp: { credit: number; }) => { + console.log(`Balance: ${resp.credit}`); + setClientsideBalance(resp.credit); + }); renderRedeemButtons().then(); }); } diff --git a/frontend/www/src/modules/modal/index.ts b/frontend/www/src/modules/modal/index.ts index 6c37420..ad49f30 100644 --- a/frontend/www/src/modules/modal/index.ts +++ b/frontend/www/src/modules/modal/index.ts @@ -3,7 +3,7 @@ import { ebsFetch } from "../../util/ebs"; import { getConfig } from "../../util/config"; import { logToDiscord } from "../../util/logger"; import { setBanned } from "../auth"; -import { clientSession, promptTransaction, transactionCancelled, transactionComplete, } from "../transaction"; +import { clientSession, promptTransaction, transactionCancelled, transactionComplete, setClientsideBalance, getClientsideBalance } from "../transaction"; import { $modalOptionsForm, checkForm, setCartArgsFromForm, setupForm } from "./form"; import { getJWTPayload as decodeJWT } from "../../util/jwt"; @@ -44,6 +44,13 @@ const $modalSuccessTitle = document.getElementById("modal-success-title")!; const $modalSuccessDescription = document.getElementById("modal-success-description")!; const $modalSuccessClose = document.getElementById("modal-success-close")!; +const $modalConfirmCredit = document.getElementById("modal-confirm-credit")!; +const $modalConfirmCreditBalance = document.getElementById("modal-confirm-credit-balance")!; +const $modalConfirmCreditCost = document.getElementById("modal-confirm-credit-cost")!; +const $modalConfirmCreditRemainder = document.getElementById("modal-confirm-credit-remainder")!; +const $modalConfirmCreditUseCredit = document.getElementById("modal-confirm-credit-useCredit")!; +const $modalConfirmCreditUseBits = document.getElementById("modal-confirm-credit-useBits")!; + export let cart: Cart | undefined; export let transactionToken: TransactionToken | undefined; export let transactionTokenJwt: string | undefined; @@ -142,6 +149,26 @@ export function showSuccessModal(title: string, description: string, onClose?: ( }; } +export function showCreditConfirmationModal(useCredit: () => void, useBits: () => void) { + if (!transactionToken) return; + const balance = getClientsideBalance(); + const cost = transactionToken!.product.cost; + + $modalConfirmCredit.style.opacity = "1"; + $modalConfirmCredit.style.pointerEvents = "unset"; + $modalConfirmCreditBalance.textContent = balance.toString(); + $modalConfirmCreditCost.textContent = cost.toString(); + $modalConfirmCreditRemainder.textContent = (balance - cost).toString(); + $modalConfirmCreditUseCredit.onclick = () => { + hideCreditConfirmationModal(); + useCredit(); + } + $modalConfirmCreditUseBits.onclick = () => { + hideCreditConfirmationModal(); + useBits(); + } +} + function closeModal() { cart = undefined; transactionToken = undefined; @@ -175,6 +202,11 @@ function hideSuccessModal(closeMainModal = false) { if (closeMainModal) closeModal(); } +function hideCreditConfirmationModal() { + $modalConfirmCredit.style.opacity = "0"; + $modalConfirmCredit.style.pointerEvents = "none"; +} + async function confirmPurchase() { setCartArgsFromForm($modalOptionsForm); if (!$modalOptionsForm.reportValidity()) { @@ -242,6 +274,7 @@ async function prePurchase(): Promise { showErrorModal("Server Error", "Server returned invalid transaction token. The developers have been notified, please try again later."); return false; } + setClientsideBalance(transactionToken.user.credit); return true; } diff --git a/frontend/www/src/modules/redeems.ts b/frontend/www/src/modules/redeems.ts index c282b32..b53aa54 100644 --- a/frontend/www/src/modules/redeems.ts +++ b/frontend/www/src/modules/redeems.ts @@ -4,6 +4,8 @@ import { getConfig } from "../util/config"; const $mainContainer = document.getElementsByTagName("main")!; const $redeemContainer = document.getElementById("items")!; const $modalProcessing = document.getElementById("modal-processing")!; +export const $balance = document.getElementById("credit-balance")!; +export const $statusBar = document.getElementById("top-status-bar")!; export async function renderRedeemButtons() { $redeemContainer.innerHTML = `

Loading content...

`; diff --git a/frontend/www/src/modules/transaction.ts b/frontend/www/src/modules/transaction.ts index 1d5b0cb..67dbd22 100644 --- a/frontend/www/src/modules/transaction.ts +++ b/frontend/www/src/modules/transaction.ts @@ -1,19 +1,24 @@ import { Transaction } from "common/types"; -import { hideProcessingModal, openModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; +import { hideProcessingModal, openModal, showCreditConfirmationModal, showErrorModal, showSuccessModal, transactionToken, transactionTokenJwt } from "./modal"; import { logToDiscord } from "../util/logger"; import { ebsFetch } from "../util/ebs"; import { twitchUseBits } from "../util/twitch"; +import { $balance, $statusBar } from "./redeems"; -type TransactionResponse = Twitch.ext.BitsTransaction | "cancelled"; +type TransactionResponse = Twitch.ext.BitsTransaction | "useCredit" | "cancelled"; +let myCredit = 0; export const clientSession = Math.random().toString(36).substring(2); export async function promptTransaction(sku: string, cost: number): Promise { - console.log(`Purchasing ${sku} for ${cost} bits`); + console.log(`Purchasing ${sku} for ${cost} bits (have ${myCredit})`); + if (myCredit >= cost && await confirmUseCredit()) { + return "useCredit"; + } return await twitchUseBits(sku); } -export async function transactionComplete(transaction: Twitch.ext.BitsTransaction) { +export async function transactionComplete(transaction: Twitch.ext.BitsTransaction | "useCredit") { if (!transactionToken) { logToDiscord({ transactionToken: null, @@ -29,12 +34,13 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio ); return; } + const isCredit = transaction === "useCredit"; logToDiscord({ transactionToken: transactionToken.id, userIdInsecure: Twitch.ext.viewer.id!, important: false, - fields: [{ header: "Transaction complete", content: transaction }], + fields: [{ header: "Transaction complete", content: isCredit ? { creditLeft: myCredit } : transaction }], }).then(); const result = await ebsFetch("/public/transaction", { @@ -43,7 +49,7 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio body: JSON.stringify({ token: transactionTokenJwt!, clientSession, - ...{ receipt: transaction.transactionReceipt }, + ...(isCredit ? { type: "credit" } : { type: "bits", receipt: transaction.transactionReceipt }), } satisfies Transaction), }); @@ -52,21 +58,43 @@ export async function transactionComplete(transaction: Twitch.ext.BitsTransactio const text = await result.text(); const cost = transactionToken.product.cost; if (result.ok) { + if (transaction === "useCredit") { + setClientsideBalance(myCredit - cost); + } showSuccessModal("Purchase completed", `${text}\nTransaction ID: ${transactionToken.id}`); } else { - const errorText = `${result.status} ${result.statusText} - ${text}`; - logToDiscord({ - transactionToken: transactionToken.id, - userIdInsecure: Twitch.ext.viewer.id!, - important: true, - fields: [{ header: "Redeem failed", content: errorText }], - }).then(); - showErrorModal( - "An error occurred.", - `${errorText} - Please contact a moderator (preferably AlexejheroDev) about the error! - Transaction ID: ${transactionToken.id}` - ); + if (transaction !== "useCredit") { + setClientsideBalance(myCredit + cost); + } + if (result.status === 400) { + logToDiscord({ + transactionToken: transactionToken.id, + userIdInsecure: Twitch.ext.viewer.id!, + important: false, + fields: [{ header: "Redeem denied", content: text }], + }).then(); + showErrorModal( + "Redeem not available", + `${text} + You have been credited the redeem cost, so you may try again later. + Transaction ID: ${transactionToken.id}` + ); + } else if (result.status === 500) { + const errorText = `${result.status} ${result.statusText} - ${text}`; + logToDiscord({ + transactionToken: transactionToken.id, + userIdInsecure: Twitch.ext.viewer.id!, + important: true, + fields: [{ header: "Redeem failed", content: errorText }], + }).then(); + showErrorModal( + "An error occurred.", + `${errorText} + Please contact a moderator (preferably AlexejheroDev) about the error! + You have been credited the redeem cost, so you may try again later. + Transaction ID: ${transactionToken.id}` + ); + } } } @@ -89,3 +117,24 @@ export async function transactionCancelled() { hideProcessingModal(); showErrorModal("Transaction cancelled.", `Transaction ID: ${transactionToken?.id ?? "none"}`); } + +export function getClientsideBalance() { + return myCredit; +} + +export async function setClientsideBalance(credit: number) { + myCredit = credit; + $balance.innerText = credit.toString(); + $statusBar.style.display = credit > 0 ? "flex" : "none"; +} + +/** + * Opens a modal asking the user if they want to use credit for the redeem + * + * @returns whether the user wants to use credit; if not, they will be prompted to use bits instead + */ +export async function confirmUseCredit(): Promise { + return new Promise((resolve) => { + showCreditConfirmationModal(() => resolve(true), () => resolve(false)); + }); +} diff --git a/scripts/sql/init_db.sql b/scripts/sql/init_db.sql index 640f5dc..fcaa59c 100644 --- a/scripts/sql/init_db.sql +++ b/scripts/sql/init_db.sql @@ -4,13 +4,14 @@ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(255) PRIMARY KEY, login VARCHAR(255), displayName VARCHAR(255), - banned BOOLEAN + banned BOOLEAN NOT NULL DEFAULT 0, + credit INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS orders ( id VARCHAR(36) PRIMARY KEY, userId VARCHAR(255) NOT NULL, - state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'failed', 'succeeded'), + state ENUM('rejected', 'prepurchase', 'cancelled', 'paid', 'denied', 'failed', 'succeeded'), cart JSON, receipt VARCHAR(1024), result TEXT, @@ -28,6 +29,17 @@ CREATE TABLE IF NOT EXISTS logs ( ); DELIMITER $$ +DROP PROCEDURE IF EXISTS addCredit +$$ +CREATE PROCEDURE addCredit(IN userId VARCHAR(255), IN delta INT, OUT result INT) +BEGIN + UPDATE users + SET credit = credit + delta + WHERE id = userId; + + SELECT credit INTO result FROM users WHERE id = userId; +END +$$ DROP PROCEDURE IF EXISTS debug $$ CREATE PROCEDURE debug()