mirror of https://gitlab.com/litecord/litecord.git
513 lines
13 KiB
Python
513 lines
13 KiB
Python
"""
|
|
|
|
Litecord
|
|
Copyright (C) 2018-2021 Luna Mendes and Litecord Contributors
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, version 3 of the License.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
|
|
import json
|
|
import datetime
|
|
from enum import Enum
|
|
|
|
from quart import Blueprint, jsonify, request, current_app as app
|
|
from logbook import Logger
|
|
|
|
from litecord.auth import token_check
|
|
from litecord.schemas import validate
|
|
from litecord.errors import BadRequest
|
|
from litecord.types import timestamp_, HOURS
|
|
from litecord.enums import UserFlags, PremiumType
|
|
from litecord.common.users import mass_user_update
|
|
|
|
log = Logger(__name__)
|
|
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
|
|
|
|
|
|
PLAN_ID_TO_TYPE = {
|
|
"premium_month_tier_1": PremiumType.TIER_1,
|
|
"premium_month_tier_2": PremiumType.TIER_2,
|
|
"premium_year_tier_1": PremiumType.TIER_1,
|
|
"premium_year_tier_2": PremiumType.TIER_2,
|
|
}
|
|
|
|
|
|
# how much should a payment be, depending
|
|
# of the subscription
|
|
AMOUNTS = {
|
|
"premium_month_tier_1": 499,
|
|
"premium_month_tier_2": 999,
|
|
"premium_year_tier_1": 4999,
|
|
"premium_year_tier_2": 9999,
|
|
}
|
|
|
|
|
|
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_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}
|
|
|
|
|
|
TO_SUB_PLAN_ID = {
|
|
"premium_month_tier_1": "511651871736201216",
|
|
"premium_month_tier_2": "511651880837840896",
|
|
"premium_year_tier_1": "511651876987469824",
|
|
"premium_year_tier_2": "511651885459963904",
|
|
}
|
|
|
|
|
|
async def get_subscription(subscription_id: int):
|
|
"""Get a subscription's information."""
|
|
row = await app.db.fetchrow(
|
|
"""
|
|
SELECT id::text, source_id::text AS payment_source_id,
|
|
user_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])
|
|
|
|
drow["items"] = [
|
|
{
|
|
"id": "123",
|
|
"plan_id": TO_SUB_PLAN_ID[drow["payment_gateway_plan_id"]],
|
|
"quantity": 1,
|
|
}
|
|
]
|
|
|
|
return drow
|
|
|
|
|
|
async def get_payment(payment_id: int):
|
|
"""Get a single payment's information."""
|
|
row = await app.db.fetchrow(
|
|
"""
|
|
SELECT id::text, source_id, subscription_id, user_id,
|
|
amount, amount_refunded, currency,
|
|
description, status, tax, tax_inclusive
|
|
FROM user_payments
|
|
WHERE id = $1
|
|
""",
|
|
payment_id,
|
|
)
|
|
|
|
drow = dict(row)
|
|
|
|
drow.pop("source_id")
|
|
drow.pop("subscription_id")
|
|
drow.pop("user_id")
|
|
|
|
drow["created_at"] = app.winter_factory.to_datetime(int(drow["id"]))
|
|
|
|
drow["payment_source"] = await get_payment_source(row["user_id"], row["source_id"])
|
|
|
|
drow["subscription"] = await get_subscription(row["subscription_id"])
|
|
|
|
return drow
|
|
|
|
|
|
async def create_payment(subscription_id):
|
|
"""Create a payment."""
|
|
sub = await get_subscription(subscription_id)
|
|
|
|
new_id = app.winter_factory.snowflake()
|
|
|
|
amount = AMOUNTS[sub["payment_gateway_plan_id"]]
|
|
|
|
await app.db.execute(
|
|
"""
|
|
INSERT INTO user_payments (
|
|
id, source_id, subscription_id, user_id,
|
|
amount, amount_refunded, currency,
|
|
description, status, tax, tax_inclusive
|
|
)
|
|
VALUES
|
|
($1, $2, $3, $4, $5, 0, $6, $7, $8, 0, false)
|
|
""",
|
|
new_id,
|
|
int(sub["payment_source_id"]),
|
|
subscription_id,
|
|
int(sub["user_id"]),
|
|
amount,
|
|
"usd",
|
|
"FUCK NITRO",
|
|
PaymentStatus.SUCCESS,
|
|
)
|
|
|
|
return new_id
|
|
|
|
|
|
async def process_subscription(subscription_id: int):
|
|
"""Process a single subscription."""
|
|
sub = await get_subscription(subscription_id)
|
|
|
|
user_id = int(sub["user_id"])
|
|
|
|
if sub["status"] != SubscriptionStatus.ACTIVE:
|
|
log.debug("ignoring sub {}, not active", subscription_id)
|
|
return
|
|
|
|
# if the subscription is still active
|
|
# (should get cancelled status on failed
|
|
# payments), then we should update premium status
|
|
first_payment_id = await app.db.fetchval(
|
|
"""
|
|
SELECT MIN(id)
|
|
FROM user_payments
|
|
WHERE subscription_id = $1
|
|
""",
|
|
subscription_id,
|
|
)
|
|
|
|
first_payment_ts = app.winter_factory.to_datetime(first_payment_id)
|
|
|
|
premium_since = await app.db.fetchval(
|
|
"""
|
|
SELECT premium_since
|
|
FROM users
|
|
WHERE id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
|
|
premium_since = premium_since or datetime.datetime.fromtimestamp(0)
|
|
|
|
delta = abs(first_payment_ts - premium_since)
|
|
|
|
# if the time difference between the first payment
|
|
# and the premium_since column is more than 24h
|
|
# we update it.
|
|
if delta.total_seconds() < 24 * HOURS:
|
|
return
|
|
|
|
old_flags = await app.db.fetchval(
|
|
"""
|
|
SELECT flags
|
|
FROM users
|
|
WHERE id = $1
|
|
""",
|
|
user_id,
|
|
)
|
|
|
|
new_flags = old_flags | UserFlags.premium_early
|
|
log.debug("updating flags {}, {} => {}", user_id, old_flags, new_flags)
|
|
|
|
await app.db.execute(
|
|
"""
|
|
UPDATE users
|
|
SET premium_since = $1, flags = $2
|
|
WHERE id = $3
|
|
""",
|
|
first_payment_ts,
|
|
new_flags,
|
|
user_id,
|
|
)
|
|
|
|
# dispatch updated user to all possible clients
|
|
await mass_user_update(user_id)
|
|
|
|
|
|
@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 = app.winter_factory.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)
|
|
|
|
source = await get_payment_source(user_id, j["payment_source_id"])
|
|
if not source:
|
|
raise BadRequest("invalid source id")
|
|
|
|
plan_id = j["payment_gateway_plan_id"]
|
|
|
|
# tier 1 is lightro / classic
|
|
# tier 2 is nitro
|
|
|
|
period_end = {
|
|
"premium_month_tier_1": "1 month",
|
|
"premium_month_tier_2": "1 month",
|
|
"premium_year_tier_1": "1 year",
|
|
"premium_year_tier_2": "1 year",
|
|
}[plan_id]
|
|
|
|
new_id = app.winter_factory.snowflake()
|
|
|
|
await app.db.execute(
|
|
f"""
|
|
INSERT INTO user_subscriptions (id, source_id, user_id,
|
|
s_type, payment_gateway, payment_gateway_plan_id,
|
|
status, period_end)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7,
|
|
now()::timestamp + interval '{period_end}')
|
|
""",
|
|
new_id,
|
|
j["payment_source_id"],
|
|
user_id,
|
|
SubscriptionType.PURCHASE,
|
|
PaymentGateway.STRIPE,
|
|
plan_id,
|
|
1,
|
|
)
|
|
|
|
await create_payment(new_id)
|
|
|
|
# make sure we update the user's premium status
|
|
# and dispatch respective user updates to other people.
|
|
await process_subscription(new_id)
|
|
|
|
return jsonify(await get_subscription(new_id))
|
|
|
|
|
|
@bp.route("/@me/billing/subscriptions/<int:subscription_id>", methods=["DELETE"])
|
|
async def _delete_subscription(subscription_id):
|
|
# user_id = await token_check()
|
|
# return '', 204
|
|
pass
|
|
|
|
|
|
@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
|
|
pass
|
|
|
|
|
|
@bp.route("/@me/billing/country-code", methods=["GET"])
|
|
async def _get_billing_country_code():
|
|
return {"country_code": "US"}
|
|
|
|
|
|
@bp.route("/@me/billing/stripe/setup-intents", methods=["POST"])
|
|
async def _stripe_setup_intents():
|
|
return {"client_secret": "gbawls"}
|
|
|
|
|
|
@bp.route("/@me/billing/payment-sources/validate-billing-address", methods=["POST"])
|
|
async def _validate_billing_address():
|
|
return {"token": "gbawls"}
|