diff --git a/cogs/isocard.py b/cogs/isocard.py index 4088c78..8337990 100644 --- a/cogs/isocard.py +++ b/cogs/isocard.py @@ -4,12 +4,15 @@ import discord import random import json +import math +from framework.isobot.isocardtxn import IsoCardTxn from framework.isobot.db.isocard import IsoCard from discord import option, ApplicationContext, SlashCommandGroup from discord.ext import commands # Variables and Functions isocard_db = IsoCard() +isocardtxn = IsoCardTxn() def generate_card_id() -> int: # Generate 16 random digits and append to a str variable @@ -126,8 +129,86 @@ async def verify_transaction(self, ctx: ApplicationContext, verification_code: i description="Please wait patiently until the merchant has verified the transaction.", color=discord.Color.green() ) + localembed.set_footer(text=f"Transaction ID: {transactions_db[str(verification_code)]['txn_id']}") await ctx.respond(embed=localembed, ephemeral=True) except KeyError: return await ctx.respond("This transaction verification code is invalid.") + @isocard.command( + name="transaction_history", + description="View all your past transactions (paid and received)" + ) + @option(name="transaction_type", description="Which type of transactions do you want to view?", type=str, choices=["paid", "received"]) + @option(name="page", description="Select the page number that you want to view (1 page = 5 logs)", type=int, default=1) + async def transaction_history(self, ctx: ApplicationContext, transaction_type: str, page: int = 1): + """View all your past transactions (paid and received)""" + transactions_db = isocardtxn.fetch_raw() + + if transaction_type == "paid": + user_transactions_paid = {} + for transaction in transactions_db: + if str(transactions_db[transaction]["payer_id"]) == str(ctx.author.id): + user_transactions_paid[transaction] = transactions_db[transaction] + + # Initial Calculation for Pages + total_pages = math.ceil(len(user_transactions_paid)/5) + if page > total_pages: page = total_pages + + log_entries = 0 + log_entries_offset = -((page-1)*5) + parsed_output = str() + sr = 0 + for transaction in user_transactions_paid: + sr += 1 + log_entries_offset += 1 + if log_entries_offset > 0: + log_entries += 1 + if log_entries <= 5: + txn_data = user_transactions_paid[transaction] + status = "" + if txn_data['status'] == "Successful": status = ":white_check_mark: Successful" + elif txn_data['status'] == "In Progress": status = ":arrows_counterclockwise: In Progress" + elif txn_data['status'] == "Terminated (insufficient balance)": status = ":x: Terminated (insufficient balance)" + elif txn_data['status'] == "Failed (unable to process payment)": status = ":warning: Failed (unable to process payment)" + parsed_output += f"{sr}. **TXN ID:** `{transaction}`\n> <@!{txn_data['payer_id']}> -> <@!{txn_data['merchant_id']}> | Amount: {txn_data['amount']} | Card Used: `{txn_data['card_number']}`\n> Status: **{status}** | \n\n" + localembed = discord.Embed( + title=f"IsoCard Transaction History for **{ctx.author.name}** (paid)", + description=parsed_output + ) + localembed.set_footer(text=f"Page {page} of {total_pages}") + return await ctx.respond(embed=localembed, ephemeral=True) + + elif transaction_type == "received": + user_transactions_received = {} + for transaction in transactions_db: + if str(transactions_db[transaction]["merchant_id"]) == str(ctx.author.id): + user_transactions_received[transaction] = transactions_db[transaction] + + # Initial Calculation for Pages + total_pages = math.ceil(len(user_transactions_received)/5) + if page > total_pages: page = total_pages + + log_entries = 0 + log_entries_offset = -((page-1)*5) + parsed_output = str() + sr = 0 + for transaction in user_transactions_received: + sr += 1 + log_entries_offset += 1 + if log_entries_offset > 0: + log_entries += 1 + if log_entries <= 5: + txn_data = user_transactions_received[transaction] + status = "" + if txn_data['status'] == "Successful": status = ":white_check_mark: Successful" + elif txn_data['status'] == "Terminated (insufficient balance)": status = ":x: Terminated (insufficient balance)" + elif txn_data['status'] == "Failed (unable to process payment)": status = ":warning: Failed (unable to process payment)" + parsed_output += f"{sr}. **TXN ID:** `{transaction}`\n> <@!{txn_data['payer_id']}> -> <@!{txn_data['merchant_id']}> | Amount: {txn_data['amount']} | Card Used: `{txn_data['card_number']}`\n> Status: **{status}** | \n\n" + localembed = discord.Embed( + title=f"IsoCard Transaction History for **{ctx.author.name}** (received)", + description=parsed_output + ) + localembed.set_footer(text=f"Page {page} of {total_pages}") + return await ctx.respond(embed=localembed, ephemeral=True) + # Initialization def setup(bot): bot.add_cog(IsoCard(bot)) diff --git a/framework/isobot/isocard.py b/framework/isobot/isocard.py index 60bc2b4..c8af0e8 100644 --- a/framework/isobot/isocard.py +++ b/framework/isobot/isocard.py @@ -3,6 +3,7 @@ import json import random import logging +from framework.isobot import isocardtxn as isocardtxn_ from api import auth from flask import Flask from flask import request @@ -13,6 +14,7 @@ log = logging.getLogger('werkzeug') log.setLevel(logging.ERROR) app = Flask('') +isocardtxn = isocardtxn_.IsoCardTxn() currency = currency.CurrencyAPI("database/currency.json", "logs/currency.log") def call_isocards_database() -> dict: @@ -36,6 +38,16 @@ def generate_verification_code() -> int: code: str = int_1 + int_2 + int_3 + int_4 + int_5 + int_6 return int(code) +def generate_txn_id() -> str: + """Generates a randomized transaction id, which is three *CAPITAL* letters followed by 6 numbers.""" + txn_id = str() + for _ in range(3): + txn_id += str(random.choice(('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ))) + txn_id += "-" + for _ in range(6): + txn_id += str(random.randint(0, 9)) + return txn_id + # API Commands @app.route('/', methods=["GET"]) def main(): @@ -53,8 +65,10 @@ def requestpayment(): merchant_id = args.get("merchantid") if str(isocards[str(card_number)]["ssc"]) == ssc: verification_code = generate_verification_code() + txn_id = generate_txn_id() user_id = isocards[str(card_number)]["cardholder_user_id"] transactions_db[str(verification_code)] = { + "txn_id": txn_id, "payer_id": user_id, "merchant_id": merchant_id, "card_number": card_number, @@ -63,9 +77,17 @@ def requestpayment(): "status": "in_progress" } save(transactions_db) + isocardtxn.write_to_log( + txn_id=txn_id, + payer_id=user_id, + reciever_id=merchant_id, + data=f"New transaction request started (txn_amount: {amount}; verification code: {verification_code})" + ) + isocardtxn.write_transaction(txn_id, user_id, merchant_id, card_number, user_id, int(amount), "In Progress") request_data = { "code": 200, "message": f"Payment requested to IsoCard number: {card_number}. Payment will be complete once user accepts this.", + "txn_id": txn_id, "verification_code": verification_code } return request_data, 200 @@ -81,28 +103,47 @@ def requestpayment(): @app.route('/checkpayment', methods=["GET"]) def checkpayment(): + with open("database/isocard_transactions.json", 'r') as f: transactions_db = json.load(f) try: - with open("database/isocard_transactions.json", 'r') as f: transactions_db = json.load(f) args = request.args verification_code = args.get("verificationcode") + txn_id: str = transactions_db[str(verification_code)]["txn_id"] if transactions_db[str(verification_code)]["status"] == "complete": if currency.get_bank(transactions_db[str(verification_code)]["payer_id"]) < transactions_db[str(verification_code)]["amount"]: + isocardtxn.write_to_log( + txn_id=txn_id, + payer_id=transactions_db[str(verification_code)]["payer_id"], + reciever_id=transactions_db[str(verification_code)]["merchant_id"], + data="Transaction has been terminated (reason: insufficient balance of the payer)" + ) del transactions_db[str(verification_code)] + save(transactions_db) + isocardtxn.update_transaction_status(txn_id, "Terminated (insufficient balance)") return { "code": 403, + "txn_id": txn_id, "message": "Transaction terminated: Insufficient payer balance.", "exception": "InsufficientFunds" }, 403 currency.bank_remove(transactions_db[str(verification_code)]["payer_id"], transactions_db[str(verification_code)]["amount"]) currency.bank_add(transactions_db[str(verification_code)]["merchant_id"], transactions_db[str(verification_code)]["amount"]) + isocardtxn.write_to_log( + txn_id=txn_id, + payer_id=transactions_db[str(verification_code)]["payer_id"], + reciever_id=transactions_db[str(verification_code)]["merchant_id"], + data=f"Payment of {transactions_db[str(verification_code)]['amount']} coins has been successful" + ) del transactions_db[str(verification_code)] save(transactions_db) + isocardtxn.update_transaction_status(txn_id, "Successful") return { "code": 200, + "txn_id": txn_id, "message": "Transaction complete." }, 200 else: return { "code": 202, + "txn_id": txn_id, "message": "Transaction still not approved." }, 202 except KeyError: return { @@ -110,9 +151,17 @@ def checkpayment(): "message": "Verification code does not point to an active transaction.", "exception": "TransactionNotFound" }, 404 - except Exception as e: return { + except Exception as e: + isocardtxn.write_to_log( + txn_id=txn_id, + payer_id=transactions_db[str(verification_code)]["payer_id"], + reciever_id=transactions_db[str(verification_code)]["merchant_id"], + data=f"Failed to process payment due to a server error (error: {e})" + ) + isocardtxn.update_transaction_status(txn_id, "Failed (unable to process payment)") + return { "code": 500, - "message": f"Failed to process payment: {e}", + "message": f"Failed to process payment due to an unhandled server error: {e}", "exception": type(e).__name__ }, 500 @@ -151,11 +200,13 @@ def account(): # Initialization def run(): app.run(host="0.0.0.0", port=4800) -if auth.get_runtime_options()["isocard_server_enabled"]: # Run server ONLY if its runtime option is enabled - print("[isocard/server] Starting IsoCard payments server...") - t = Thread(target=run) - t.daemon = True - t.start() +def deploy_server(): + """Deploys the IsoCard Payments Server. (if the option is enabled in the runtimeconfig file)\n\nRuntimeconfig Option: `isocard_server_enabled`""" + if auth.get_runtime_options()["isocard_server_enabled"]: # Run server ONLY if its runtime option is enabled + print("[isocard/server] Starting IsoCard payments server...") + t = Thread(target=run) + t.daemon = True + t.start() #btw i use arch diff --git a/framework/isobot/isocardtxn.py b/framework/isobot/isocardtxn.py new file mode 100644 index 0000000..0bcb234 --- /dev/null +++ b/framework/isobot/isocardtxn.py @@ -0,0 +1,188 @@ +# Imports +import json +import time +import datetime +from typing_extensions import Union + +# Variables +log_file_path = "logs/isocard_transactions.log" + +# Initialization +class IsoCardTxn: + # Pre-defined Methods + def read(self) -> dict: + """ + # `read()` Command + ## Command Information + Reads the latest data from the transaction database, and returns it. + + ### Note: This command should only be used for internal module use. + ### To use this elsewhere, use the alternate command `fetch_raw()`. + """ + with open("database/isocard_transaction_history.json", 'r', encoding="utf-8") as f: + txn_db = json.load(f) + return txn_db + + def save(self, data: dict) -> int: + """ + # `save()` Command + ## Command Information + Dumps the latest transaction data from memory to local machine storage. + + ## Status Return Codes + ### If successful: + - Returns `0` + ### If not successful: + - Returns the respective exception class + """ + with open("database/isocard_transaction_history.json", 'w+', encoding="utf-8") as f: + json.dump(data, f, indent=4) + return 0 + + def write_to_log(self, txn_id: str, payer_id: Union[str, int], reciever_id: Union[str, int], data: str) -> int: + """ + # `write_to_log()` Command + ## Command Information + Writes a new transaction update to the specified log path. + + ## Status Return Codes + ### If successful: + - Returns `0` + ### If not successful: + - Returns the respective exception class + + ## Log Format + Each log update is written to the log file at `logs/isocard_transactions.log`, whenever the command is fired during runtime. + + The log format is provided as follows: + + ```log + [current time] (payer id -> receiver id) transaction id: status update data + ``` + """ + current_time = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S") + with open(log_file_path, 'a') as f: + f.write(f"[{current_time}] ({str(payer_id)} -> {str(reciever_id)}) {txn_id}: {data}\n") + return 0 + + # Functions + def read_transaction(self, txn_id: str) -> dict: + """ + # `read_transactions()` Command + ## Command Information + Reads and returns the data of a transaction from the history log with the given transaction id. + + ## Exception Handling + ### Transaction does not exist in the transactions database: + - Returns error response code`1` + + ## Response Format + All successful responses will be provided in the following `dict` format: + + ```json + txn_id: { + "payer_id": user id of the payer, + "merchant_id": user id of the merchant (reciever), + "card_number": IsoCard number of the payer, + "user_id": the id of the user paying, + "amount": amount of coins requested by merchant, + "status": current status of the transaction, + "timestamp": time at which the transaction was logged + } + ``` + """ + try: + with open("database/isocard_transaction_history.json", 'r', encoding="utf-8") as f: + txn_db = json.load(f) + return txn_db[str(txn_id)] + except KeyError: + return 1 + + def write_transaction(self, txn_id: str, payer_id: str, merchant_id: str, card_number: str, user_id: str, amount: int, status: str) -> int: + """ + # `write_transaction()` Command + ## Command Information + Writes a new transaction to the transaction history log. + + ## Status Return Codes + ### If successful: + - Returns `0` + ### If not successful: + - Returns the respective exception class + + ## Log Database Format + Each transaction in the transaction history database is stored in the following format: + + ```json + txn_id: { + "payer_id": user id of the payer, + "merchant_id": user id of the merchant (reciever), + "card_number": IsoCard number of the payer, + "user_id": the id of the user paying, + "amount": amount of coins requested by merchant, + "status": current status of the transaction, + "timestamp": time at which the transaction was logged + } + ``` + + - Note: This format can be refered to, while working with the output from the `read_transaction()` command. + """ + with open("database/isocard_transaction_history.json", 'r', encoding="utf-8") as f: + txn_db = json.load(f) + txn_db[str(txn_id)] = { + "payer_id": payer_id, + "merchant_id": merchant_id, + "card_number": card_number, + "user_id": user_id, + "amount": amount, + "status": status, + "timestamp": round(time.time()), + } + self.save(txn_db) + return 0 + + def update_transaction_status(self, txn_id: str, new_status: str) -> int: + """ + # `update_transaction_status()` Command + ## Command Information + Updates the status field of a transaction in transaction history. + + ## Status Return Codes + ### If successful: + - Returns `0` + ### If transaction does not exist: + - Returns `1` + """ + try: + with open("database/isocard_transaction_history.json", 'r', encoding="utf-8") as f: + txn_db = json.load(f) + txn_db[str(txn_id)]["status"] = new_status + self.save(txn_db) + return 0 + except KeyError: + return 1 + + def fetch_raw(self) -> dict: + """ + # `fetch_raw()` Command + ## Command Information + Fetches all of the raw data from the transactions database as `dict.` + + ## Transaction Log Format + Each log in the transactions database is given in the following format: + + ```json + txn_id: { + "payer_id": user id of the payer, + "merchant_id": user id of the merchant (reciever), + "card_number": IsoCard number of the payer, + "user_id": the id of the user paying, + "amount": amount of coins requested by merchant, + "status": current status of the transaction, + "timestamp": time at which the transaction was logged + } + ``` + """ + with open("database/isocard_transaction_history.json", 'r', encoding="utf-8") as f: + txn_db = json.load(f) + return txn_db diff --git a/main.py b/main.py index a62f574..86b07b9 100644 --- a/main.py +++ b/main.py @@ -55,6 +55,7 @@ def initial_setup(): "weather", "embeds", "isocard_transactions", + "isocard_transaction_history", "isobank/accounts", "isobank/auth" ) @@ -87,13 +88,13 @@ def initial_setup(): try: if not os.path.isfile("logs/info-log.txt"): with open('logs/info-log.txt', 'x', encoding="utf-8") as this: - this.write("#All information and warnings will be logged here!\n") + this.write("# All information and warnings will be logged here!\n") this.close() logger.info("Created info log", module="main/Setup", nolog=True) time.sleep(0.5) if not os.path.isfile("logs/error-log.txt"): with open('logs/error-log.txt', 'x', encoding="utf-8") as this: - this.write("#All exceptions will be logged here!\n") + this.write("# All exceptions will be logged here!\n") this.close() logger.info("Created error log", module="main/Setup", nolog=True) time.sleep(0.5) @@ -106,6 +107,11 @@ def initial_setup(): with open("logs/startup-log.txt", 'x', encoding="utf-8") as this: this.close() time.sleep(0.5) + if not os.path.isfile("logs/isocard_transactions.log"): + with open("logs/isocard_transactions.log", 'x', encoding="utf-8") as this: + this.write("# All IsoCard transaction updates will be logged here.\n") + this.close() + time.sleep(0.5) except IOError as e: logger.error(f"Failed to make log file: {e}", module="main/Setup", nolog=True) @@ -161,7 +167,9 @@ async def on_ready(): s.log(f'[main/Client] Logged in as {client.user.name}. Start time: {start_time.strftime("%H:%M:%S")}\n[main/Client] Ready to accept commands. Click Ctrl+C to shut down the bot.') await client.change_presence(activity=discord.Activity(type=discord.ActivityType.playing, name="I-I-I be poppin bottles 🗣🗣🔥"), status=discord.Status.idle) s.log(f'[main/Log] {colors.green}Status set to IDLE. Rich presence set.{colors.end}') - + # Deploy IsoCard Payments Server + isocard.deploy_server() + time.sleep(0.5) # Start and Deploy Ping Server if api.auth.get_mode() or api.auth.get_runtime_options()["ping_server_override"]: # If ping_server_override is set to true, it will start the pinging server no matter what. If it's set to false, it will only start if client mode is set to replit.