diff --git a/litecord/blueprints/user/__init__.py b/litecord/blueprints/user/__init__.py new file mode 100644 index 0000000..07c6cac --- /dev/null +++ b/litecord/blueprints/user/__init__.py @@ -0,0 +1,2 @@ +from .billing import bp as user_billing +from .settings import bp as user_settings diff --git a/litecord/blueprints/user/billing.py b/litecord/blueprints/user/billing.py new file mode 100644 index 0000000..bb6f7b1 --- /dev/null +++ b/litecord/blueprints/user/billing.py @@ -0,0 +1,264 @@ +import pprint +import json +from enum import Enum + +from quart import Blueprint, jsonify, request, current_app as app + +from litecord.auth import token_check +from litecord.schemas import validate +from litecord.blueprints.checks import guild_check +from litecord.storage import timestamp_ +from litecord.snowflake import snowflake_datetime, get_snowflake + +bp = Blueprint('users_billing', __name__) + + + + +class PaymentSource(Enum): + CREDIT = 1 + PAYPAL = 2 + + +class SubscriptionStatus: + ACTIVE = 1 + CANCELLED = 3 + + +class SubscriptionType: + # unknown + PURCHASE = 1 + UPGRADE = 2 + + +class SubscriptionPlan: + CLASSIC = 1 + NITRO = 2 + + +class PaymentGateway: + STRIPE = 1 + BRAINTREE = 2 + + +class PaymentStatus: + SUCCESS = 1 + FAILED = 2 + + +CREATE_SUBSCRIPTION = { + 'payment_gateway_plan_id': {'type': 'string'}, + 'payment_source_id': {'coerce': int} +} + + +PAYMENT_SOURCE = { + 'billing_address': { + 'type': 'dict', + 'schema': { + 'country': {'type': 'string', 'required': True}, + 'city': {'type': 'string', 'required': True}, + 'name': {'type': 'string', 'required': True}, + 'line_1': {'type': 'string', 'required': False}, + 'line_2': {'type': 'string', 'required': False}, + 'postal_code': {'type': 'string', 'required': True}, + 'state': {'type': 'string', 'required': True}, + } + }, + 'payment_gateway': {'type': 'number', 'required': True}, + 'token': {'type': 'string', 'required': True}, +} + + +async def get_payment_source_ids(user_id: int) -> list: + rows = await app.db.fetch(""" + SELECT id + FROM user_payment_sources + WHERE user_id = $1 + """, user_id) + + return [r['id'] for r in rows] + + +async def get_payment_ids(user_id: int) -> list: + rows = await app.db.fetch(""" + SELECT id + FROM user_payments + WHERE user_id = $1 + """, user_id) + + return [r['id'] for r in rows] + + +async def get_subscription_ids(user_id: int) -> list: + rows = await app.db.fetch(""" + SELECT id + FROM user_subscriptions + WHERE user_id = $1 + """, user_id) + + return [r['id'] for r in rows] + + +async def get_payment_source(user_id: int, source_id: int) -> dict: + """Get a payment source's information.""" + source = {} + + source_type = await app.db.fetchval(""" + SELECT source_type + FROM user_payment_sources + WHERE id = $1 AND user_id = $2 + """, source_id, user_id) + + source_type = PaymentSource(source_type) + + specific_fields = { + PaymentSource.PAYPAL: ['paypal_email'], + PaymentSource.CREDIT: ['expires_month', 'expires_year', + 'brand', 'cc_full'] + }[source_type] + + fields = ','.join(specific_fields) + + extras_row = await app.db.fetchrow(f""" + SELECT {fields}, billing_address, default_, id::text + FROM user_payment_sources + WHERE id = $1 + """, source_id) + + derow = dict(extras_row) + + if source_type == PaymentSource.CREDIT: + derow['last_4'] = derow['cc_full'][-4:] + derow.pop('cc_full') + + derow['default'] = derow['default_'] + derow.pop('default_') + + source = { + 'id': str(source_id), + 'type': source_type.value, + } + + return {**source, **derow} + + +async def get_subscription(subscription_id: int): + row = await app.db.execute(""" + SELECT id::text, source_id::text AS payment_source_id, + payment_gateway, payment_gateway_plan_id, + period_start AS current_period_start, + period_end AS current_period_end, + canceled_at, s_type, status + FROM user_subscriptions + WHERE id = $1 + """, subscription_id) + + drow = dict(row) + + drow['type'] = drow['s_type'] + drow.pop('s_type') + + to_tstamp = ['current_period_start', 'current_period_end', 'canceled_at'] + + for field in to_tstamp: + drow[field] = timestamp_(drow[field]) + + return drow + + +async def get_payment(payment_id: int): + row = await app.db.execute(""" + SELECT id::text, source_id, subscription_id, + amount, amount_refunded, currency, + description, status, tax, tax_inclusive + FROM user_payments + WHERE id = $1 + """, payment_id) + + drow = dict(row) + drow['created_at'] = snowflake_datetime(int(drow['id'])) + return drow + + +@bp.route('/@me/billing/payment-sources', methods=['GET']) +async def _get_billing_sources(): + user_id = await token_check() + source_ids = await get_payment_source_ids(user_id) + + res = [] + + for source_id in source_ids: + source = await get_payment_source(user_id, source_id) + res.append(source) + + return jsonify(res) + + +@bp.route('/@me/billing/subscriptions', methods=['GET']) +async def _get_billing_subscriptions(): + user_id = await token_check() + sub_ids = await get_subscription_ids(user_id) + res = [] + + for sub_id in sub_ids: + res.append(await get_subscription(sub_id)) + + return jsonify(res) + + +@bp.route('/@me/billing/payments', methods=['GET']) +async def _get_billing_payments(): + user_id = await token_check() + payment_ids = await get_payment_ids(user_id) + res = [] + + for payment_id in payment_ids: + res.append(await get_payment(payment_id)) + + return jsonify(res) + + +@bp.route('/@me/billing/payment-sources', methods=['POST']) +async def _create_payment_source(): + user_id = await token_check() + j = validate(await request.get_json(), PAYMENT_SOURCE) + + new_source_id = get_snowflake() + + await app.db.execute( + """ + INSERT INTO user_payment_sources (id, user_id, source_type, + default_, expires_month, expires_year, brand, cc_full, + billing_address) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + """, new_source_id, user_id, PaymentSource.CREDIT.value, + True, 12, 6969, 'Visa', '4242424242424242', + json.dumps(j['billing_address'])) + + return jsonify( + await get_payment_source(user_id, new_source_id) + ) + + +@bp.route('/@me/billing/subscriptions', methods=['POST']) +async def _create_subscription(): + user_id = await token_check() + j = validate(await request.get_json(), CREATE_SUBSCRIPTION) + + +@bp.route('/@me/billing/subscriptions/', + methods=['DELETE']) +async def _delete_subscription(subscription_id): + user_id = await token_check() + return '', 204 + + +@bp.route('/@me/billing/subscriptions/', + methods=['PATCH']) +async def _patch_subscription(subscription_id): + """change a subscription's payment source""" + user_id = await token_check() + j = validate(await request.get_json(), PATCH_SUBSCRIPTION) + # returns subscription object + diff --git a/litecord/blueprints/user/settings.py b/litecord/blueprints/user/settings.py new file mode 100644 index 0000000..ae003a4 --- /dev/null +++ b/litecord/blueprints/user/settings.py @@ -0,0 +1,127 @@ +from quart import Blueprint, jsonify, request, current_app as app + +from litecord.auth import token_check +from litecord.schemas import validate, USER_SETTINGS, GUILD_SETTINGS +from litecord.blueprints.checks import guild_check + +bp = Blueprint('users_settings', __name__) + + +@bp.route('/@me/settings', methods=['GET']) +async def get_user_settings(): + """Get the current user's settings.""" + user_id = await token_check() + settings = await app.storage.get_user_settings(user_id) + return jsonify(settings) + + +@bp.route('/@me/settings', methods=['PATCH']) +async def patch_current_settings(): + """Patch the users' current settings. + + More information on what settings exist + is at Storage.get_user_settings and the schema.sql file. + """ + user_id = await token_check() + j = validate(await request.get_json(), USER_SETTINGS) + + for key in j: + await app.db.execute(f""" + UPDATE user_settings + SET {key}=$1 + WHERE id = $2 + """, j[key], user_id) + + settings = await app.storage.get_user_settings(user_id) + await app.dispatcher.dispatch_user( + user_id, 'USER_SETTINGS_UPDATE', settings) + return jsonify(settings) + + +@bp.route('/@me/guilds//settings', methods=['PATCH']) +async def patch_guild_settings(guild_id: int): + """Update the users' guild settings for a given guild. + + Guild settings are usually related to notifications. + """ + user_id = await token_check() + await guild_check(user_id, guild_id) + + j = validate(await request.get_json(), GUILD_SETTINGS) + + # querying the guild settings information before modifying + # will make sure they exist in the table. + await app.storage.get_guild_settings_one(user_id, guild_id) + + for field in (k for k in j.keys() if k != 'channel_overrides'): + await app.db.execute(f""" + UPDATE guild_settings + SET {field} = $1 + WHERE user_id = $2 AND guild_id = $3 + """, j[field], user_id, guild_id) + + chan_ids = await app.storage.get_channel_ids(guild_id) + + for chandata in j.get('channel_overrides', {}).items(): + chan_id, chan_overrides = chandata + chan_id = int(chan_id) + + # ignore channels that aren't in the guild. + if chan_id not in chan_ids: + continue + + for field in chan_overrides: + await app.db.execute(f""" + INSERT INTO guild_settings_channel_overrides + (user_id, guild_id, channel_id, {field}) + VALUES + ($1, $2, $3, $4) + ON CONFLICT + ON CONSTRAINT guild_settings_channel_overrides_pkey + DO + UPDATE + SET {field} = $4 + WHERE guild_settings_channel_overrides.user_id = $1 + AND guild_settings_channel_overrides.guild_id = $2 + AND guild_settings_channel_overrides.channel_id = $3 + """, user_id, guild_id, chan_id, chan_overrides[field]) + + settings = await app.storage.get_guild_settings_one(user_id, guild_id) + + await app.dispatcher.dispatch_user( + user_id, 'USER_GUILD_SETTINGS_UPDATE', settings) + + return jsonify(settings) + + +@bp.route('/@me/notes/', methods=['PUT']) +async def put_note(target_id: int): + """Put a note to a user. + + This route is in this blueprint because I consider + notes to be personalized settings, so. + """ + user_id = await token_check() + + j = await request.get_json() + note = str(j['note']) + + # UPSERTs are beautiful + await app.db.execute(""" + INSERT INTO notes (user_id, target_id, note) + VALUES ($1, $2, $3) + + ON CONFLICT ON CONSTRAINT notes_pkey + DO UPDATE SET + note = $3 + WHERE notes.user_id = $1 + AND notes.target_id = $2 + """, user_id, target_id, note) + + await app.dispatcher.dispatch_user(user_id, 'USER_NOTE_UPDATE', { + 'id': str(target_id), + 'note': note, + }) + + return '', 204 + diff --git a/litecord/blueprints/users.py b/litecord/blueprints/users.py index f5d16e5..3070ac5 100644 --- a/litecord/blueprints/users.py +++ b/litecord/blueprints/users.py @@ -242,6 +242,7 @@ async def get_me_guilds(): @bp.route('/@me/guilds/', methods=['DELETE']) async def leave_guild(guild_id: int): + """Leave a guild.""" user_id = await token_check() await guild_check(user_id, guild_id) @@ -275,65 +276,6 @@ async def get_connections(): pass -@bp.route('/@me/notes/', methods=['PUT']) -async def put_note(target_id: int): - """Put a note to a user.""" - user_id = await token_check() - - j = await request.get_json() - note = str(j['note']) - - # UPSERTs are beautiful - await app.db.execute(""" - INSERT INTO notes (user_id, target_id, note) - VALUES ($1, $2, $3) - - ON CONFLICT ON CONSTRAINT notes_pkey - DO UPDATE SET - note = $3 - WHERE notes.user_id = $1 - AND notes.target_id = $2 - """, user_id, target_id, note) - - await app.dispatcher.dispatch_user(user_id, 'USER_NOTE_UPDATE', { - 'id': str(target_id), - 'note': note, - }) - - return '', 204 - - -@bp.route('/@me/settings', methods=['GET']) -async def get_user_settings(): - """Get the current user's settings.""" - user_id = await token_check() - settings = await app.storage.get_user_settings(user_id) - return jsonify(settings) - - -@bp.route('/@me/settings', methods=['PATCH']) -async def patch_current_settings(): - """Patch the users' current settings. - - More information on what settings exist - is at Storage.get_user_settings and the schema.sql file. - """ - user_id = await token_check() - j = validate(await request.get_json(), USER_SETTINGS) - - for key in j: - await app.db.execute(f""" - UPDATE user_settings - SET {key}=$1 - WHERE id = $2 - """, j[key], user_id) - - settings = await app.storage.get_user_settings(user_id) - await app.dispatcher.dispatch_user( - user_id, 'USER_SETTINGS_UPDATE', settings) - return jsonify(settings) - - @bp.route('/@me/consent', methods=['GET', 'POST']) async def get_consent(): """Always disable data collection. @@ -420,59 +362,3 @@ async def get_profile(peer_id: int): 'premium_since': peer_premium, 'mutual_guilds': mutual_res, }) - - -@bp.route('/@me/guilds//settings', methods=['PATCH']) -async def patch_guild_settings(guild_id: int): - """Update the users' guild settings for a given guild. - - Guild settings are usually related to notifications. - """ - user_id = await token_check() - await guild_check(user_id, guild_id) - - j = validate(await request.get_json(), GUILD_SETTINGS) - - # querying the guild settings information before modifying - # will make sure they exist in the table. - await app.storage.get_guild_settings_one(user_id, guild_id) - - for field in (k for k in j.keys() if k != 'channel_overrides'): - await app.db.execute(f""" - UPDATE guild_settings - SET {field} = $1 - WHERE user_id = $2 AND guild_id = $3 - """, j[field], user_id, guild_id) - - chan_ids = await app.storage.get_channel_ids(guild_id) - - for chandata in j.get('channel_overrides', {}).items(): - chan_id, chan_overrides = chandata - chan_id = int(chan_id) - - # ignore channels that aren't in the guild. - if chan_id not in chan_ids: - continue - - for field in chan_overrides: - await app.db.execute(f""" - INSERT INTO guild_settings_channel_overrides - (user_id, guild_id, channel_id, {field}) - VALUES - ($1, $2, $3, $4) - ON CONFLICT - ON CONSTRAINT guild_settings_channel_overrides_pkey - DO - UPDATE - SET {field} = $4 - WHERE guild_settings_channel_overrides.user_id = $1 - AND guild_settings_channel_overrides.guild_id = $2 - AND guild_settings_channel_overrides.channel_id = $3 - """, user_id, guild_id, chan_id, chan_overrides[field]) - - settings = await app.storage.get_guild_settings_one(user_id, guild_id) - - await app.dispatcher.dispatch_user( - user_id, 'USER_GUILD_SETTINGS_UPDATE', settings) - - return jsonify(settings) diff --git a/litecord/schemas.py b/litecord/schemas.py index a234c41..a5c4460 100644 --- a/litecord/schemas.py +++ b/litecord/schemas.py @@ -69,7 +69,8 @@ class LitecordValidator(Validator): def _validate_type_voice_region(self, value: str) -> bool: # TODO: complete this list - return value.lower() in ('brazil', 'us-east', 'us-west', 'us-south', 'russia') + return value.lower() in ('brazil', 'us-east', 'us-west', + 'us-south', 'russia') def _validate_type_verification_level(self, value: int) -> bool: return _in_enum(VerificationLevel, value) diff --git a/run.py b/run.py index 56d2be0..8e0d632 100644 --- a/run.py +++ b/run.py @@ -28,6 +28,10 @@ from litecord.blueprints.channel import ( channel_messages, channel_reactions, channel_pins ) +from litecord.blueprints.user import ( + user_settings, user_billing +) + from litecord.ratelimits.handler import ratelimit_handler from litecord.ratelimits.main import RatelimitManager @@ -68,7 +72,10 @@ def set_blueprints(app_): bps = { gateway: None, auth: '/auth', + users: '/users', + user_settings: '/users', + user_billing: '/users', relationships: '/users', guilds: '/guilds', diff --git a/schema.sql b/schema.sql index dd1b56c..4f25172 100644 --- a/schema.sql +++ b/schema.sql @@ -156,6 +156,83 @@ CREATE TABLE IF NOT EXISTS user_settings ( ); +-- main user billing tables +CREATE TABLE IF NOT EXISTS user_payment_sources ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users (id) NOT NULL, + + -- type=1: credit card fields + -- type=2: paypal fields + source_type int, + + -- idk lol + invalid bool DEFAULT false, + default_ bool DEFAULT false, + + -- credit card info (type 1 only) + expires_month int DEFAULT 12, + expires_year int DEFAULT 3000, + brand text, + cc_full text NOT NULL, + + -- paypal info (type 2 only) + paypal_email text DEFAULT 'a@a.com', + + -- applies to both + billing_address jsonb DEFAULT '{}' +); + +-- actual subscription statuses +CREATE TABLE IF NOT EXISTS user_subscriptions ( + id bigint PRIMARY KEY, + source_id bigint REFERENCES user_payment_sources (id) NOT NULL, + user_id bigint REFERENCES users (id) NOT NULL, + + -- s_type = 1: purchase + -- s_type = 2: upgrade + s_type int DEFAULT 1, + + -- gateway = 1: stripe + -- gateway = 2: braintree + payment_gateway int DEFAULT 0, + payment_gateway_plan_id text, + + -- status = 1: active + -- status = 3: cancelled + status int DEFAULT 1, + + canceled_at timestamp without time zone default NULL, + + -- set by us + period_start timestamp without time zone default (now() at time zone 'utc'), + period_end timestamp without time zone default NULL +); + +-- payment logs +CREATE TABLE IF NOT EXISTS user_payments ( + id bigint PRIMARY KEY, + source_id bigint REFERENCES user_payment_sources (id), + subscription_id bigint REFERENCES user_subscriptions (id), + user_id bigint REFERENCES users (id), + + currency text DEFAULT 'usd', + + -- status = 1: success + -- status = 2: failed + status int DEFAULT 1, + + -- 499 = 4 dollars 99 cents + amount bigint, + + tax int DEFAULT 0, + tax_inclusive BOOL default true, + + description text, + + amount_refunded int DEFAULT 0 +); + + -- main user relationships CREATE TABLE IF NOT EXISTS relationships ( -- the id of who made the relationship