mirror of https://gitlab.com/litecord/litecord.git
litecord.blueprints: split users bp into user sub-bp
- blueprints.user: add biling bp
- blueprints.user: add settings bp
- schema.sql: add user_payment_sources, user_subscriptions,
user_payments
This commit is contained in:
parent
49aa6cd495
commit
b9ef4c6d8c
|
|
@ -0,0 +1,2 @@
|
|||
from .billing import bp as user_billing
|
||||
from .settings import bp as user_settings
|
||||
|
|
@ -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/<int:subscription_id>',
|
||||
methods=['DELETE'])
|
||||
async def _delete_subscription(subscription_id):
|
||||
user_id = await token_check()
|
||||
return '', 204
|
||||
|
||||
|
||||
@bp.route('/@me/billing/subscriptions/<int:subscription_id>',
|
||||
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
|
||||
|
||||
|
|
@ -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/<int:guild_id>/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/<int:target_id>', 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
|
||||
|
||||
|
|
@ -242,6 +242,7 @@ async def get_me_guilds():
|
|||
|
||||
@bp.route('/@me/guilds/<int:guild_id>', 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/<int:target_id>', 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/<int:guild_id>/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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
run.py
7
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',
|
||||
|
|
|
|||
77
schema.sql
77
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue