You've already forked AstralRinth
Charge tax on products (#4361)
* Initial Anrok integration * Query cache, fmt, clippy * Fmt * Use payment intent function in edit_subscription * Attach Anrok client, use payments in index_billing * Integrate Anrok with refunds * Bug fixes * More bugfixes * Fix resubscriptions * Medal promotion bugfixes * Use stripe metadata constants everywhere * Pre-fill values in products_tax_identifiers * Cleanup billing route module * Cleanup * Email notification for tax charge * Don't charge tax on users which haven't been notified of tax change * Fix taxnotification.amount templates * Update .env.docker-compose * Update .env.local * Clippy * Fmt * Query cache * Periodically update tax amount on upcoming charges * Fix queries * Skip indexing tax amount on charges if no charges to process * chore: query cache, clippy, fmt * Fix a lot of things * Remove test code * chore: query cache, clippy, fmt * Fix money formatting * Fix conflicts * Extra documentation, handle tax association properly * Track loss in tax drift * chore: query cache, clippy, fmt * Add subscription.id variable * chore: query cache, clippy, fmt * chore: query cache, clippy, fmt
This commit is contained in:
committed by
GitHub
parent
47020f34b6
commit
4228a193e9
@@ -138,4 +138,7 @@ AVALARA_1099_COMPANY_ID=207337084
|
||||
|
||||
COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -139,4 +139,7 @@ AVALARA_1099_COMPANY_ID=207337084
|
||||
|
||||
COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE id = ANY($1::bigint[])",
|
||||
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE id = ANY($1::bigint[])",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"ordinal": 2,
|
||||
"name": "unitary",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -27,8 +32,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b"
|
||||
"hash": "042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60"
|
||||
}
|
||||
40
apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json
generated
Normal file
40
apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n INNER JOIN products_prices pp ON pp.id = $1\n\t\t\tWHERE products.id = pp.product_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "metadata",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "unitary",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83"
|
||||
}
|
||||
34
apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json
generated
Normal file
34
apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM products_tax_identifiers WHERE product_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tax_processor_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "product_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
|
||||
"hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE parent_charge_id = $1",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE parent_charge_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"
|
||||
"hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
|
||||
"hash": "6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
|
||||
"hash": "7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6"
|
||||
}
|
||||
34
apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json
generated
Normal file
34
apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tSELECT pti.*\n\t\t\tFROM products_prices pp\n\t\t\tINNER JOIN products_tax_identifiers pti ON pti.product_id = pp.product_id\n\t\t\tWHERE pp.id = $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tax_processor_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "product_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE id = $1",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"
|
||||
"hash": "9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca"
|
||||
}
|
||||
130
apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json
generated
Normal file
130
apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n\t\t\t AND tax_amount <> 0\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE 1 = $1",
|
||||
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE 1 = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"ordinal": 2,
|
||||
"name": "unitary",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -27,8 +32,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d"
|
||||
"hash": "aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf"
|
||||
}
|
||||
40
apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json
generated
Normal file
40
apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json
generated
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n products_tax_identifiers.*,\n products.metadata product_metadata\n FROM products_prices\n INNER JOIN products ON products.id = products_prices.product_id\n INNER JOIN products_tax_identifiers ON products_tax_identifiers.product_id = products.id\n WHERE products_prices.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tax_processor_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "product_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "product_metadata",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b"
|
||||
}
|
||||
32
apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json
generated
Normal file
32
apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362"
|
||||
"hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,28 +55,48 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "payment_platform",
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "payment_platform_id?",
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -95,12 +115,16 @@
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
|
||||
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1",
|
||||
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE metadata ->> 'type' = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,6 +17,11 @@
|
||||
"ordinal": 2,
|
||||
"name": "unitary",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -27,8 +32,9 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8"
|
||||
"hash": "f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263"
|
||||
}
|
||||
130
apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json
generated
Normal file
130
apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tINNER JOIN users u ON u.id = charges.user_id\n\t\t\tWHERE\n\t\t\t status = 'open'\n\t\t\t AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'\n\t\t\t AND u.email IS NOT NULL\n\t\t\t AND due - INTERVAL '7 days' > NOW()\n\t\t\tORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a"
|
||||
}
|
||||
24
apps/labrinth/migrations/20250910145542_tax-charges.sql
Normal file
24
apps/labrinth/migrations/20250910145542_tax-charges.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE charges ADD COLUMN tax_amount BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE charges ADD COLUMN tax_platform_id TEXT;
|
||||
|
||||
ALTER TABLE products ADD COLUMN name TEXT;
|
||||
|
||||
CREATE TABLE products_tax_identifiers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tax_processor_id TEXT NOT NULL,
|
||||
product_id BIGINT REFERENCES products (id) NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO products_tax_identifiers (tax_processor_id, product_id)
|
||||
SELECT
|
||||
'modrinth-servers' AS tax_processor_id,
|
||||
id AS product_id
|
||||
FROM products
|
||||
WHERE metadata ->> 'type' = 'pyro';
|
||||
|
||||
INSERT INTO products_tax_identifiers (tax_processor_id, product_id)
|
||||
SELECT
|
||||
'modrinth-plus' AS tax_processor_id,
|
||||
id AS product_id
|
||||
FROM products
|
||||
WHERE metadata ->> 'type' = 'midas';
|
||||
@@ -0,0 +1,45 @@
|
||||
ALTER TABLE charges ADD COLUMN tax_last_updated TIMESTAMPTZ;
|
||||
ALTER TABLE charges ADD COLUMN tax_drift_loss BIGINT;
|
||||
|
||||
INSERT INTO notifications_types
|
||||
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
|
||||
VALUES ('tax_notification', 2, FALSE, FALSE);
|
||||
|
||||
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
|
||||
VALUES (NULL, 'email', 'tax_notification', TRUE);
|
||||
|
||||
INSERT INTO notifications_templates
|
||||
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
|
||||
VALUES
|
||||
(
|
||||
'email',
|
||||
'tax_notification',
|
||||
'Your subscription''s tax is changing',
|
||||
'https://modrinth.com/email/subscription-tax-change',
|
||||
CONCAT(
|
||||
'Hi {user.name},',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Your {taxnotification.service} subscription''s tax rate is changing. Starting with your next {taxnotification.billing_interval} payment, your {taxnotification.service} subscription''s, your charge and all future charges will be updated as follows:',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Current subtotal: {taxnotification.old_amount}',
|
||||
CHR(10),
|
||||
'Current tax: {taxnotification.old_tax_amount}',
|
||||
CHR(10),
|
||||
'Current TOTAL: {taxnotification.old_total_amount}',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'New subtotal: {taxnotification.new_amount}',
|
||||
CHR(10),
|
||||
'New tax: {taxnotification.new_tax_amount}',
|
||||
CHR(10),
|
||||
'New TOTAL: {taxnotification.new_total_amount}',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Note that the pre-tax price of your subscription has not changed, only the tax charged has changed as required by local tax regulations.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thank your for using {taxnotification.service}.'
|
||||
)
|
||||
);
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::queue::payouts::{
|
||||
PayoutsQueue, index_payouts_notifications,
|
||||
insert_bank_balances_and_webhook, process_payout,
|
||||
};
|
||||
use crate::search::indexing::index_projects;
|
||||
use crate::util::anrok;
|
||||
use crate::{database, search};
|
||||
use clap::ValueEnum;
|
||||
use sqlx::Postgres;
|
||||
@@ -24,6 +26,7 @@ pub enum BackgroundTask {
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run(
|
||||
self,
|
||||
pool: sqlx::Pool<Postgres>,
|
||||
@@ -31,6 +34,7 @@ impl BackgroundTask {
|
||||
search_config: search::SearchConfig,
|
||||
clickhouse: clickhouse::Client,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
) {
|
||||
use BackgroundTask::*;
|
||||
@@ -41,8 +45,9 @@ impl BackgroundTask {
|
||||
UpdateVersions => update_versions(pool, redis_pool).await,
|
||||
Payouts => payouts(pool, clickhouse, redis_pool).await,
|
||||
IndexBilling => {
|
||||
crate::routes::internal::billing::index_billing(
|
||||
index_billing(
|
||||
stripe_client,
|
||||
anrok_client,
|
||||
pool.clone(),
|
||||
redis_pool,
|
||||
)
|
||||
@@ -51,8 +56,11 @@ impl BackgroundTask {
|
||||
update_bank_balances(pool).await;
|
||||
}
|
||||
IndexSubscriptions => {
|
||||
crate::routes::internal::billing::index_subscriptions(
|
||||
pool, redis_pool,
|
||||
index_subscriptions(
|
||||
pool,
|
||||
redis_pool,
|
||||
stripe_client,
|
||||
anrok_client,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::models::billing::{
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DBCharge {
|
||||
pub id: DBChargeId,
|
||||
pub user_id: DBUserId,
|
||||
@@ -26,8 +27,13 @@ pub struct DBCharge {
|
||||
|
||||
pub parent_charge_id: Option<DBChargeId>,
|
||||
|
||||
pub tax_amount: i64,
|
||||
pub tax_platform_id: Option<String>,
|
||||
pub tax_last_updated: Option<DateTime<Utc>>,
|
||||
|
||||
// Net is always in USD
|
||||
pub net: Option<i64>,
|
||||
pub tax_drift_loss: Option<i64>,
|
||||
}
|
||||
|
||||
struct ChargeQueryResult {
|
||||
@@ -45,7 +51,11 @@ struct ChargeQueryResult {
|
||||
payment_platform: String,
|
||||
payment_platform_id: Option<String>,
|
||||
parent_charge_id: Option<i64>,
|
||||
tax_amount: i64,
|
||||
tax_platform_id: Option<String>,
|
||||
tax_last_updated: Option<DateTime<Utc>>,
|
||||
net: Option<i64>,
|
||||
tax_drift_loss: Option<i64>,
|
||||
}
|
||||
|
||||
impl TryFrom<ChargeQueryResult> for DBCharge {
|
||||
@@ -69,7 +79,11 @@ impl TryFrom<ChargeQueryResult> for DBCharge {
|
||||
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
|
||||
payment_platform_id: r.payment_platform_id,
|
||||
parent_charge_id: r.parent_charge_id.map(DBChargeId),
|
||||
tax_amount: r.tax_amount,
|
||||
tax_platform_id: r.tax_platform_id,
|
||||
net: r.net,
|
||||
tax_last_updated: r.tax_last_updated,
|
||||
tax_drift_loss: r.tax_drift_loss,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,14 +94,16 @@ macro_rules! select_charges_with_predicate {
|
||||
ChargeQueryResult,
|
||||
r#"
|
||||
SELECT
|
||||
id, user_id, price_id, amount, currency_code, status, due, last_attempt,
|
||||
charge_type, subscription_id,
|
||||
charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,
|
||||
charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,
|
||||
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
|
||||
subscription_interval AS "subscription_interval?",
|
||||
payment_platform,
|
||||
payment_platform_id AS "payment_platform_id?",
|
||||
parent_charge_id AS "parent_charge_id?",
|
||||
net AS "net?"
|
||||
charges.subscription_interval AS "subscription_interval?",
|
||||
charges.payment_platform,
|
||||
charges.payment_platform_id AS "payment_platform_id?",
|
||||
charges.parent_charge_id AS "parent_charge_id?",
|
||||
charges.net AS "net?",
|
||||
charges.tax_last_updated AS "tax_last_updated?",
|
||||
charges.tax_drift_loss AS "tax_drift_loss?"
|
||||
FROM charges
|
||||
"#
|
||||
+ $predicate,
|
||||
@@ -103,8 +119,8 @@ impl DBCharge {
|
||||
) -> Result<DBChargeId, DatabaseError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
@@ -116,10 +132,14 @@ impl DBCharge {
|
||||
payment_platform_id = EXCLUDED.payment_platform_id,
|
||||
parent_charge_id = EXCLUDED.parent_charge_id,
|
||||
net = EXCLUDED.net,
|
||||
tax_amount = EXCLUDED.tax_amount,
|
||||
tax_platform_id = EXCLUDED.tax_platform_id,
|
||||
tax_last_updated = EXCLUDED.tax_last_updated,
|
||||
price_id = EXCLUDED.price_id,
|
||||
amount = EXCLUDED.amount,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
charge_type = EXCLUDED.charge_type
|
||||
charge_type = EXCLUDED.charge_type,
|
||||
tax_drift_loss = EXCLUDED.tax_drift_loss
|
||||
"#,
|
||||
self.id.0,
|
||||
self.user_id.0,
|
||||
@@ -136,6 +156,10 @@ impl DBCharge {
|
||||
self.payment_platform_id.as_deref(),
|
||||
self.parent_charge_id.map(|x| x.0),
|
||||
self.net,
|
||||
self.tax_amount,
|
||||
self.tax_platform_id.as_deref(),
|
||||
self.tax_last_updated,
|
||||
self.tax_drift_loss,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
@@ -276,6 +300,71 @@ impl DBCharge {
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?)
|
||||
}
|
||||
|
||||
/// Returns all charges that need to have their tax amount updated.
|
||||
///
|
||||
/// This only selects charges which are:
|
||||
/// - Open;
|
||||
/// - Haven't been updated in the last day;
|
||||
/// - Are due in more than 7 days;
|
||||
/// - Where the user has an email, because we can't notify users without an email about a price change.
|
||||
///
|
||||
/// This also locks the charges.
|
||||
pub async fn get_updateable_lock(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DBCharge>, DatabaseError> {
|
||||
let res = select_charges_with_predicate!(
|
||||
"
|
||||
INNER JOIN users u ON u.id = charges.user_id
|
||||
WHERE
|
||||
status = 'open'
|
||||
AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'
|
||||
AND u.email IS NOT NULL
|
||||
AND due - INTERVAL '7 days' > NOW()
|
||||
ORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
",
|
||||
limit
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(res
|
||||
.into_iter()
|
||||
.map(|r| r.try_into())
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?)
|
||||
}
|
||||
|
||||
/// Returns all charges which are missing a tax identifier, that is, are 1. succeeded, 2. have a tax amount and
|
||||
/// 3. haven't been assigned a tax identifier yet.
|
||||
///
|
||||
/// Charges are locked.
|
||||
pub async fn get_missing_tax_identifier_lock(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<DBCharge>, DatabaseError> {
|
||||
let res = select_charges_with_predicate!(
|
||||
"
|
||||
WHERE
|
||||
status = 'succeeded'
|
||||
AND tax_platform_id IS NULL
|
||||
AND tax_amount <> 0
|
||||
ORDER BY due ASC
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
",
|
||||
limit
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(res
|
||||
.into_iter()
|
||||
.map(|r| r.try_into())
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
id: DBChargeId,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
@@ -293,3 +382,9 @@ impl DBCharge {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CustomerCharge {
|
||||
pub stripe_customer_id: String,
|
||||
pub charge: DBCharge,
|
||||
pub product_tax_id: String,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod pat_item;
|
||||
pub mod payout_item;
|
||||
pub mod payouts_values_notifications;
|
||||
pub mod product_item;
|
||||
pub mod products_tax_identifier_item;
|
||||
pub mod project_item;
|
||||
pub mod report_item;
|
||||
pub mod session_item;
|
||||
|
||||
@@ -15,20 +15,22 @@ pub struct DBProduct {
|
||||
pub id: DBProductId,
|
||||
pub metadata: ProductMetadata,
|
||||
pub unitary: bool,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
struct ProductQueryResult {
|
||||
id: i64,
|
||||
metadata: serde_json::Value,
|
||||
unitary: bool,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
macro_rules! select_products_with_predicate {
|
||||
($predicate:tt, $param:ident) => {
|
||||
($predicate:tt, $param:expr) => {
|
||||
sqlx::query_as!(
|
||||
ProductQueryResult,
|
||||
r#"
|
||||
SELECT id, metadata, unitary
|
||||
SELECT products.id, products.metadata, products.unitary, products.name
|
||||
FROM products
|
||||
"#
|
||||
+ $predicate,
|
||||
@@ -45,6 +47,7 @@ impl TryFrom<ProductQueryResult> for DBProduct {
|
||||
id: DBProductId(r.id),
|
||||
metadata: serde_json::from_value(r.metadata)?,
|
||||
unitary: r.unitary,
|
||||
name: r.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,6 +60,23 @@ impl DBProduct {
|
||||
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn get_price(
|
||||
id: DBProductPriceId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<DBProduct>, DatabaseError> {
|
||||
let maybe_row = select_products_with_predicate!(
|
||||
"INNER JOIN products_prices pp ON pp.id = $1
|
||||
WHERE products.id = pp.product_id",
|
||||
id.0
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
maybe_row
|
||||
.map(|r| r.try_into().map_err(Into::into))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn get_by_type<'a, E>(
|
||||
exec: E,
|
||||
r#type: &str,
|
||||
@@ -116,6 +136,8 @@ pub struct QueryProductWithPrices {
|
||||
pub id: DBProductId,
|
||||
pub metadata: ProductMetadata,
|
||||
pub unitary: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub name: Option<String>,
|
||||
pub prices: Vec<DBProductPrice>,
|
||||
}
|
||||
|
||||
@@ -152,6 +174,7 @@ impl QueryProductWithPrices {
|
||||
Some(QueryProductWithPrices {
|
||||
id: x.id,
|
||||
metadata: x.metadata,
|
||||
name: x.name,
|
||||
prices: prices
|
||||
.remove(&x.id)
|
||||
.map(|x| x.1)?
|
||||
@@ -195,6 +218,7 @@ impl QueryProductWithPrices {
|
||||
Some(QueryProductWithPrices {
|
||||
id: x.id,
|
||||
metadata: x.metadata,
|
||||
name: x.name,
|
||||
prices: prices
|
||||
.remove(&x.id)
|
||||
.map(|x| x.1)?
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::database::models::ids::{DBProductId, DBProductPriceId};
|
||||
use crate::models::billing::ProductMetadata;
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub struct DBProductsTaxIdentifier {
|
||||
pub id: i32,
|
||||
pub tax_processor_id: String,
|
||||
pub product_id: DBProductId,
|
||||
}
|
||||
|
||||
impl DBProductsTaxIdentifier {
|
||||
pub async fn get_product(
|
||||
product_id: DBProductId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<Self>, ApiError> {
|
||||
let maybe_row = sqlx::query!(
|
||||
"SELECT * FROM products_tax_identifiers WHERE product_id = $1",
|
||||
product_id.0,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
|
||||
id: row.id,
|
||||
tax_processor_id: row.tax_processor_id,
|
||||
product_id: DBProductId(row.product_id),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_price(
|
||||
price_id: DBProductPriceId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<Self>, ApiError> {
|
||||
let maybe_row = sqlx::query!(
|
||||
"
|
||||
SELECT pti.*
|
||||
FROM products_prices pp
|
||||
INNER JOIN products_tax_identifiers pti ON pti.product_id = pp.product_id
|
||||
WHERE pp.id = $1
|
||||
",
|
||||
price_id.0,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|row| DBProductsTaxIdentifier {
|
||||
id: row.id,
|
||||
tax_processor_id: row.tax_processor_id,
|
||||
product_id: DBProductId(row.product_id),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProductInfo {
|
||||
pub tax_identifier: DBProductsTaxIdentifier,
|
||||
pub product_metadata: ProductMetadata,
|
||||
}
|
||||
|
||||
pub async fn product_info_by_product_price_id(
|
||||
product_price_id: DBProductPriceId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<Option<ProductInfo>, ApiError> {
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
products_tax_identifiers.*,
|
||||
products.metadata product_metadata
|
||||
FROM products_prices
|
||||
INNER JOIN products ON products.id = products_prices.product_id
|
||||
INNER JOIN products_tax_identifiers ON products_tax_identifiers.product_id = products.id
|
||||
WHERE products_prices.id = $1
|
||||
"#,
|
||||
product_price_id.0 as i64,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
match maybe_row {
|
||||
None => Ok(None),
|
||||
Some(row) => Ok(Some(ProductInfo {
|
||||
tax_identifier: DBProductsTaxIdentifier {
|
||||
id: row.id,
|
||||
tax_processor_id: row.tax_processor_id,
|
||||
product_id: DBProductId(row.product_id),
|
||||
},
|
||||
product_metadata: serde_json::from_value(row.product_metadata)?,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::database::models::{
|
||||
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
|
||||
};
|
||||
use crate::models::billing::{
|
||||
PriceDuration, SubscriptionMetadata, SubscriptionStatus,
|
||||
PriceDuration, ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use itertools::Itertools;
|
||||
@@ -161,3 +161,12 @@ impl DBUserSubscription {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SubscriptionWithCharge {
|
||||
pub subscription_id: DBUserSubscriptionId,
|
||||
pub user_id: DBUserId,
|
||||
pub product_metadata: ProductMetadata,
|
||||
pub amount: i64,
|
||||
pub tax_amount: i64,
|
||||
pub due: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ use util::cors::default_cors;
|
||||
|
||||
use crate::background_task::update_versions;
|
||||
use crate::database::ReadOnlyPgPool;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::moderation::AutomatedModerationQueue;
|
||||
use crate::util::anrok;
|
||||
use crate::util::env::{parse_strings_from_var, parse_var};
|
||||
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
|
||||
use sync::friends::handle_pubsub;
|
||||
@@ -58,6 +60,7 @@ pub struct LabrinthConfig {
|
||||
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
|
||||
pub rate_limiter: web::Data<AsyncRateLimiter>,
|
||||
pub stripe_client: stripe::Client,
|
||||
pub anrok_client: anrok::Client,
|
||||
pub email_queue: web::Data<EmailQueue>,
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ pub fn app_setup(
|
||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
enable_background_tasks: bool,
|
||||
) -> LabrinthConfig {
|
||||
@@ -161,10 +165,12 @@ pub fn app_setup(
|
||||
let pool_ref = pool.clone();
|
||||
let redis_ref = redis_pool.clone();
|
||||
let stripe_client_ref = stripe_client.clone();
|
||||
let anrok_client_ref = anrok_client.clone();
|
||||
actix_rt::spawn(async move {
|
||||
loop {
|
||||
routes::internal::billing::index_billing(
|
||||
index_billing(
|
||||
stripe_client_ref.clone(),
|
||||
anrok_client_ref.clone(),
|
||||
pool_ref.clone(),
|
||||
redis_ref.clone(),
|
||||
)
|
||||
@@ -175,11 +181,16 @@ pub fn app_setup(
|
||||
|
||||
let pool_ref = pool.clone();
|
||||
let redis_ref = redis_pool.clone();
|
||||
let stripe_client_ref = stripe_client.clone();
|
||||
let anrok_client_ref = anrok_client.clone();
|
||||
|
||||
actix_rt::spawn(async move {
|
||||
loop {
|
||||
routes::internal::billing::index_subscriptions(
|
||||
index_subscriptions(
|
||||
pool_ref.clone(),
|
||||
redis_ref.clone(),
|
||||
stripe_client_ref.clone(),
|
||||
anrok_client_ref.clone(),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_secs(60 * 5)).await;
|
||||
@@ -287,6 +298,7 @@ pub fn app_setup(
|
||||
automated_moderation_queue,
|
||||
rate_limiter: limiter,
|
||||
stripe_client,
|
||||
anrok_client,
|
||||
email_queue: web::Data::new(email_queue),
|
||||
}
|
||||
}
|
||||
@@ -322,6 +334,7 @@ pub fn app_config(
|
||||
.app_data(labrinth_config.active_sockets.clone())
|
||||
.app_data(labrinth_config.automated_moderation_queue.clone())
|
||||
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
|
||||
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
|
||||
.app_data(labrinth_config.rate_limiter.clone())
|
||||
.configure({
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -501,6 +514,9 @@ pub fn check_env_vars() -> bool {
|
||||
failed |= check_var::<String>("AVALARA_1099_API_TEAM_ID");
|
||||
failed |= check_var::<String>("AVALARA_1099_COMPANY_ID");
|
||||
|
||||
failed |= check_var::<String>("ANROK_API_URL");
|
||||
failed |= check_var::<String>("ANROK_API_KEY");
|
||||
|
||||
failed |= check_var::<String>("COMPLIANCE_PAYOUT_THRESHOLD");
|
||||
|
||||
failed |= check_var::<String>("PAYOUT_ALERT_SLACK_WEBHOOK");
|
||||
|
||||
@@ -8,6 +8,7 @@ use labrinth::database::redis::RedisPool;
|
||||
use labrinth::file_hosting::{S3BucketConfig, S3Host};
|
||||
use labrinth::queue::email::EmailQueue;
|
||||
use labrinth::search;
|
||||
use labrinth::util::anrok;
|
||||
use labrinth::util::env::parse_var;
|
||||
use labrinth::util::ratelimit::rate_limit_middleware;
|
||||
use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue};
|
||||
@@ -136,6 +137,7 @@ async fn main() -> std::io::Result<()> {
|
||||
let stripe_client =
|
||||
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
||||
|
||||
let anrok_client = anrok::Client::from_env().unwrap();
|
||||
let email_queue =
|
||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||
|
||||
@@ -147,6 +149,7 @@ async fn main() -> std::io::Result<()> {
|
||||
search_config,
|
||||
clickhouse,
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
email_queue,
|
||||
)
|
||||
.await;
|
||||
@@ -186,6 +189,7 @@ async fn main() -> std::io::Result<()> {
|
||||
file_host.clone(),
|
||||
maxmind_reader.clone(),
|
||||
stripe_client,
|
||||
anrok_client.clone(),
|
||||
email_queue,
|
||||
!args.no_background_tasks,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::models::ids::{ThreadMessageId, VersionId};
|
||||
use crate::models::v3::billing::PriceDuration;
|
||||
use crate::models::{
|
||||
ids::{
|
||||
NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId,
|
||||
UserSubscriptionId,
|
||||
},
|
||||
notifications::{Notification, NotificationAction, NotificationBody},
|
||||
projects::ProjectStatus,
|
||||
@@ -37,6 +39,17 @@ pub struct LegacyNotificationAction {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LegacyNotificationBody {
|
||||
TaxNotification {
|
||||
subscription_id: UserSubscriptionId,
|
||||
old_amount: i64,
|
||||
old_tax_amount: i64,
|
||||
new_amount: i64,
|
||||
new_tax_amount: i64,
|
||||
billing_interval: PriceDuration,
|
||||
currency: String,
|
||||
due: DateTime<Utc>,
|
||||
service: String,
|
||||
},
|
||||
ProjectUpdate {
|
||||
project_id: ProjectId,
|
||||
version_id: VersionId,
|
||||
@@ -198,6 +211,9 @@ impl LegacyNotification {
|
||||
NotificationBody::PaymentFailed { .. } => {
|
||||
Some("payment_failed".to_string())
|
||||
}
|
||||
NotificationBody::TaxNotification { .. } => {
|
||||
Some("tax_notification".to_string())
|
||||
}
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
Some("payout_available".to_string())
|
||||
}
|
||||
@@ -341,6 +357,27 @@ impl LegacyNotification {
|
||||
new_email,
|
||||
to_email,
|
||||
},
|
||||
NotificationBody::TaxNotification {
|
||||
subscription_id,
|
||||
old_amount,
|
||||
old_tax_amount,
|
||||
new_amount,
|
||||
new_tax_amount,
|
||||
billing_interval,
|
||||
currency,
|
||||
due,
|
||||
service,
|
||||
} => LegacyNotificationBody::TaxNotification {
|
||||
subscription_id,
|
||||
old_amount,
|
||||
old_tax_amount,
|
||||
new_amount,
|
||||
new_tax_amount,
|
||||
billing_interval,
|
||||
due,
|
||||
service,
|
||||
currency,
|
||||
},
|
||||
NotificationBody::PaymentFailed { amount, service } => {
|
||||
LegacyNotificationBody::PaymentFailed { amount, service }
|
||||
}
|
||||
|
||||
@@ -66,6 +66,15 @@ pub enum Price {
|
||||
},
|
||||
}
|
||||
|
||||
impl Price {
|
||||
pub fn get_interval(&self, interval: PriceDuration) -> Option<i32> {
|
||||
match self {
|
||||
Price::OneTime { .. } => None,
|
||||
Price::Recurring { intervals } => intervals.get(&interval).copied(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PriceDuration {
|
||||
@@ -175,6 +184,16 @@ pub enum SubscriptionMetadata {
|
||||
Medal { id: String },
|
||||
}
|
||||
|
||||
impl SubscriptionMetadata {
|
||||
pub fn is_medal(&self) -> bool {
|
||||
matches!(self, SubscriptionMetadata::Medal { .. })
|
||||
}
|
||||
|
||||
pub fn is_pyro(&self) -> bool {
|
||||
matches!(self, SubscriptionMetadata::Pyro { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Charge {
|
||||
pub id: ChargeId,
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::ids::*;
|
||||
use crate::database::models::notification_item::DBNotification;
|
||||
use crate::database::models::notification_item::DBNotificationAction;
|
||||
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
|
||||
use crate::models::billing::PriceDuration;
|
||||
use crate::models::ids::{
|
||||
NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId,
|
||||
VersionId,
|
||||
@@ -46,6 +47,7 @@ pub enum NotificationType {
|
||||
PasswordRemoved,
|
||||
EmailChanged,
|
||||
PaymentFailed,
|
||||
TaxNotification,
|
||||
PatCreated,
|
||||
ModerationMessageReceived,
|
||||
ReportStatusUpdated,
|
||||
@@ -76,7 +78,9 @@ impl NotificationType {
|
||||
NotificationType::PasswordRemoved => "password_removed",
|
||||
NotificationType::EmailChanged => "email_changed",
|
||||
NotificationType::PaymentFailed => "payment_failed",
|
||||
NotificationType::TaxNotification => "tax_notification",
|
||||
NotificationType::PatCreated => "pat_created",
|
||||
NotificationType::PayoutAvailable => "payout_available",
|
||||
NotificationType::ModerationMessageReceived => {
|
||||
"moderation_message_received"
|
||||
}
|
||||
@@ -87,7 +91,6 @@ impl NotificationType {
|
||||
}
|
||||
NotificationType::ProjectStatusNeutral => "project_status_neutral",
|
||||
NotificationType::ProjectTransferred => "project_transferred",
|
||||
NotificationType::PayoutAvailable => "payout_available",
|
||||
NotificationType::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -110,18 +113,7 @@ impl NotificationType {
|
||||
"password_removed" => NotificationType::PasswordRemoved,
|
||||
"email_changed" => NotificationType::EmailChanged,
|
||||
"payment_failed" => NotificationType::PaymentFailed,
|
||||
"pat_created" => NotificationType::PatCreated,
|
||||
"moderation_message_received" => {
|
||||
NotificationType::ModerationMessageReceived
|
||||
}
|
||||
"report_status_updated" => NotificationType::ReportStatusUpdated,
|
||||
"report_submitted" => NotificationType::ReportSubmitted,
|
||||
"project_status_approved" => {
|
||||
NotificationType::ProjectStatusApproved
|
||||
}
|
||||
"project_status_neutral" => NotificationType::ProjectStatusNeutral,
|
||||
"project_transferred" => NotificationType::ProjectTransferred,
|
||||
"payout_available" => NotificationType::PayoutAvailable,
|
||||
"tax_notification" => NotificationType::TaxNotification,
|
||||
"unknown" => NotificationType::Unknown,
|
||||
_ => NotificationType::Unknown,
|
||||
}
|
||||
@@ -218,6 +210,17 @@ pub enum NotificationBody {
|
||||
amount: String,
|
||||
service: String,
|
||||
},
|
||||
TaxNotification {
|
||||
subscription_id: UserSubscriptionId,
|
||||
new_amount: i64,
|
||||
new_tax_amount: i64,
|
||||
old_amount: i64,
|
||||
old_tax_amount: i64,
|
||||
billing_interval: PriceDuration,
|
||||
currency: String,
|
||||
due: DateTime<Utc>,
|
||||
service: String,
|
||||
},
|
||||
PayoutAvailable {
|
||||
date_available: DateTime<Utc>,
|
||||
amount: f64,
|
||||
@@ -293,6 +296,9 @@ impl NotificationBody {
|
||||
NotificationBody::PaymentFailed { .. } => {
|
||||
NotificationType::PaymentFailed
|
||||
}
|
||||
NotificationBody::TaxNotification { .. } => {
|
||||
NotificationType::TaxNotification
|
||||
}
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
NotificationType::PayoutAvailable
|
||||
}
|
||||
@@ -522,6 +528,12 @@ impl From<DBNotification> for Notification {
|
||||
"#".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::TaxNotification { .. } => (
|
||||
"Tax notification".to_string(),
|
||||
"You've received a tax notification.".to_string(),
|
||||
"#".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::PayoutAvailable { .. } => (
|
||||
"Payout available".to_string(),
|
||||
"A payout is available!".to_string(),
|
||||
|
||||
908
apps/labrinth/src/queue/billing.rs
Normal file
908
apps/labrinth/src/queue/billing.rs
Normal file
@@ -0,0 +1,908 @@
|
||||
use crate::database::models::charge_item::DBCharge;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::product_item::DBProduct;
|
||||
use crate::database::models::products_tax_identifier_item::DBProductsTaxIdentifier;
|
||||
use crate::database::models::user_item::DBUser;
|
||||
use crate::database::models::user_subscription_item::DBUserSubscription;
|
||||
use crate::database::models::users_redeemals::UserRedeemal;
|
||||
use crate::database::models::{DatabaseError, ids::*};
|
||||
use crate::database::models::{
|
||||
product_item, user_subscription_item, users_redeemals,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::billing::{
|
||||
ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration,
|
||||
ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
|
||||
};
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::models::users::Badges;
|
||||
use crate::models::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::billing::payments::*;
|
||||
use crate::util::anrok;
|
||||
use crate::util::archon::ArchonClient;
|
||||
use crate::util::archon::{CreateServerRequest, Specs};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use chrono::Utc;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashSet;
|
||||
use std::str::FromStr;
|
||||
use stripe::{self, Currency};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub async fn index_subscriptions(
|
||||
pool: PgPool,
|
||||
redis: RedisPool,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
) {
|
||||
info!("Indexing subscriptions");
|
||||
|
||||
async fn anrok_api_operations(
|
||||
pool: PgPool,
|
||||
redis: RedisPool,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
) {
|
||||
let then = std::time::Instant::now();
|
||||
let result = update_tax_amounts(
|
||||
pool.clone(),
|
||||
redis.clone(),
|
||||
anrok_client.clone(),
|
||||
stripe_client.clone(),
|
||||
100,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("Error updating tax amount on charges: {:?}", e);
|
||||
}
|
||||
|
||||
let result = update_tax_transactions(
|
||||
pool,
|
||||
redis,
|
||||
anrok_client,
|
||||
stripe_client,
|
||||
100,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("Error updating tax transactions: {:?}", e);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Updating tax amounts and Anrok transactions took {:?}",
|
||||
then.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates charges which need to have their tax amount updated. This is done within a timer to avoid reaching
|
||||
/// Anrok API limits.
|
||||
///
|
||||
/// The global rate limit for Anrok API operations is 10 RPS, so we run ~6 requests every second up
|
||||
/// to the specified limit of processed charges.
|
||||
async fn update_tax_amounts(
|
||||
pg: PgPool,
|
||||
redis: RedisPool,
|
||||
anrok_client: anrok::Client,
|
||||
stripe_client: stripe::Client,
|
||||
limit: i64,
|
||||
) -> Result<(), ApiError> {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
interval
|
||||
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let mut processed_charges = 0;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let mut txn = pg.begin().await?;
|
||||
|
||||
let charges = DBCharge::get_updateable_lock(&mut *txn, 6).await?;
|
||||
|
||||
if charges.is_empty() {
|
||||
info!("No more charges to process");
|
||||
break Ok(());
|
||||
}
|
||||
|
||||
let anrok_client_ref = anrok_client.clone();
|
||||
let stripe_client_ref = stripe_client.clone();
|
||||
let pg_ref = pg.clone();
|
||||
let redis_ref = redis.clone();
|
||||
|
||||
struct ProcessedCharge {
|
||||
charge: DBCharge,
|
||||
new_tax_amount: i64,
|
||||
product_name: String,
|
||||
}
|
||||
|
||||
let mut futures = charges
|
||||
.into_iter()
|
||||
.map(|charge| {
|
||||
let stripe_client = stripe_client_ref.clone();
|
||||
let anrok_client = anrok_client_ref.clone();
|
||||
let pg = pg_ref.clone();
|
||||
let redis = redis_ref.clone();
|
||||
|
||||
async move {
|
||||
let stripe_customer_id =
|
||||
DBUser::get_id(charge.user_id, &pg, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(DatabaseError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
))
|
||||
})
|
||||
.and_then(|user| {
|
||||
user.stripe_customer_id.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"User has no Stripe customer ID"
|
||||
.to_owned(),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let tax_id = DBProductsTaxIdentifier::get_price(
|
||||
charge.price_id,
|
||||
&pg,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DatabaseError::Database(sqlx::Error::RowNotFound)
|
||||
})?;
|
||||
|
||||
let product =
|
||||
DBProduct::get_price(charge.price_id, &pg)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DatabaseError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
)
|
||||
})?;
|
||||
|
||||
let Ok(customer_id): Result<stripe::CustomerId, _> =
|
||||
stripe_customer_id.parse()
|
||||
else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Charge's Stripe customer ID was invalid"
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let customer = stripe::Customer::retrieve(
|
||||
&stripe_client,
|
||||
&customer_id,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(stripe_address) = customer.address else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Stripe customer had no address".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let customer_address =
|
||||
anrok::Address::from_stripe_address(
|
||||
&stripe_address,
|
||||
);
|
||||
|
||||
let tax_amount = anrok_client
|
||||
.create_ephemeral_txn(&anrok::TransactionFields {
|
||||
customer_address,
|
||||
currency_code: charge.currency_code.clone(),
|
||||
accounting_time: charge.due,
|
||||
accounting_time_zone:
|
||||
anrok::AccountingTimeZone::Utc,
|
||||
line_items: vec![anrok::LineItem::new(
|
||||
tax_id.tax_processor_id,
|
||||
charge.amount,
|
||||
)],
|
||||
})
|
||||
.await?
|
||||
.tax_amount_to_collect;
|
||||
|
||||
Result::<ProcessedCharge, ApiError>::Ok(
|
||||
ProcessedCharge {
|
||||
charge,
|
||||
new_tax_amount: tax_amount,
|
||||
product_name: product
|
||||
.name
|
||||
.unwrap_or_else(|| "Modrinth".to_owned()),
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
|
||||
while let Some(result) = futures.next().await {
|
||||
processed_charges += 1;
|
||||
|
||||
match result {
|
||||
Ok(ProcessedCharge {
|
||||
mut charge,
|
||||
new_tax_amount,
|
||||
product_name,
|
||||
}) => {
|
||||
charge.tax_last_updated = Some(Utc::now());
|
||||
|
||||
if new_tax_amount != charge.tax_amount {
|
||||
// The price of the subscription has changed, we need to insert a notification
|
||||
// for this.
|
||||
|
||||
let Some(subscription_id) = charge.subscription_id
|
||||
else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Charge has no subscription ID".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
NotificationBuilder {
|
||||
body: NotificationBody::TaxNotification {
|
||||
subscription_id: subscription_id.into(),
|
||||
new_amount: charge.amount,
|
||||
new_tax_amount,
|
||||
old_amount: charge.amount,
|
||||
old_tax_amount: charge.tax_amount,
|
||||
billing_interval: charge
|
||||
.subscription_interval
|
||||
.unwrap_or(PriceDuration::Monthly),
|
||||
due: charge.due,
|
||||
service: product_name,
|
||||
currency: charge.currency_code.clone(),
|
||||
},
|
||||
}
|
||||
.insert(charge.user_id, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
charge.tax_amount = new_tax_amount;
|
||||
}
|
||||
|
||||
charge.upsert(&mut txn).await?;
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "Error indexing tax amount on charge");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
if processed_charges >= limit {
|
||||
break Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers Anrok transactions for charges which are missing a tax identifier.
|
||||
///
|
||||
/// Same as update_tax_amounts, this is done within a timer to avoid reaching Anrok API limits.
|
||||
///
|
||||
/// The global rate limit for Anrok API operations is 10 RPS, so we run ~6 requests every second up
|
||||
/// to the specified limit of processed charges.
|
||||
async fn update_tax_transactions(
|
||||
pg: PgPool,
|
||||
redis: RedisPool,
|
||||
anrok_client: anrok::Client,
|
||||
stripe_client: stripe::Client,
|
||||
limit: i64,
|
||||
) -> Result<(), ApiError> {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
interval
|
||||
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
let mut processed_charges = 0;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let mut txn = pg.begin().await?;
|
||||
|
||||
let charges =
|
||||
DBCharge::get_missing_tax_identifier_lock(&mut *txn, 6).await?;
|
||||
|
||||
if charges.is_empty() {
|
||||
info!("No more charges to process");
|
||||
break Ok(());
|
||||
}
|
||||
|
||||
for mut c in charges {
|
||||
let payment_intent_id = c
|
||||
.payment_platform_id
|
||||
.as_deref()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Charge has no or an invalid payment platform ID"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let stripe_customer_id = DBUser::get_id(c.user_id, &pg, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::from(DatabaseError::Database(
|
||||
sqlx::Error::RowNotFound,
|
||||
))
|
||||
})
|
||||
.and_then(|user| {
|
||||
user.stripe_customer_id.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"User has no Stripe customer ID".to_owned(),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
let tax_id =
|
||||
DBProductsTaxIdentifier::get_price(c.price_id, &pg)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DatabaseError::Database(sqlx::Error::RowNotFound)
|
||||
})?;
|
||||
|
||||
let Ok(customer_id): Result<stripe::CustomerId, _> =
|
||||
stripe_customer_id.parse()
|
||||
else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Charge's Stripe customer ID was invalid".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let customer = stripe::Customer::retrieve(
|
||||
&stripe_client,
|
||||
&customer_id,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(stripe_address) = customer.address else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Stripe customer had no address".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let customer_address =
|
||||
anrok::Address::from_stripe_address(&stripe_address);
|
||||
|
||||
let tax_platform_id =
|
||||
anrok::transaction_id_stripe_pi(&payment_intent_id);
|
||||
|
||||
// Note: if the tax amount that was charged to the customer is *different* than
|
||||
// what it *should* be NOW, we will take on a loss here.
|
||||
|
||||
let should_have_collected = anrok_client
|
||||
.create_or_update_txn(&anrok::Transaction {
|
||||
id: tax_platform_id.clone(),
|
||||
fields: anrok::TransactionFields {
|
||||
customer_address,
|
||||
currency_code: c.currency_code.clone(),
|
||||
accounting_time: c.due,
|
||||
accounting_time_zone:
|
||||
anrok::AccountingTimeZone::Utc,
|
||||
line_items: vec![
|
||||
anrok::LineItem::new_including_tax_amount(
|
||||
tax_id.tax_processor_id,
|
||||
c.tax_amount + c.amount,
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.tax_amount_to_collect;
|
||||
|
||||
let drift = should_have_collected - c.tax_amount;
|
||||
|
||||
c.tax_drift_loss = Some(drift);
|
||||
c.tax_platform_id = Some(tax_platform_id);
|
||||
c.upsert(&mut txn).await?;
|
||||
|
||||
processed_charges += 1;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
if processed_charges >= limit {
|
||||
break Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tax_charges_index_handle = tokio::spawn(anrok_api_operations(
|
||||
pool.clone(),
|
||||
redis.clone(),
|
||||
stripe_client.clone(),
|
||||
anrok_client.clone(),
|
||||
));
|
||||
|
||||
let res = async {
|
||||
let mut transaction = pool.begin().await?;
|
||||
let mut clear_cache_users = Vec::new();
|
||||
|
||||
// If an active subscription has:
|
||||
// - A canceled charge due now
|
||||
// - An expiring charge due now
|
||||
// - A failed charge more than two days ago
|
||||
// It should be unprovisioned.
|
||||
let all_charges = DBCharge::get_unprovision(&pool).await?;
|
||||
|
||||
let mut all_subscriptions =
|
||||
user_subscription_item::DBUserSubscription::get_many(
|
||||
&all_charges
|
||||
.iter()
|
||||
.filter_map(|x| x.subscription_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
)
|
||||
.await?;
|
||||
let subscription_prices = product_item::DBProductPrice::get_many(
|
||||
&all_subscriptions
|
||||
.iter()
|
||||
.map(|x| x.price_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
)
|
||||
.await?;
|
||||
let subscription_products = product_item::DBProduct::get_many(
|
||||
&subscription_prices
|
||||
.iter()
|
||||
.map(|x| x.product_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
)
|
||||
.await?;
|
||||
let users = DBUser::get_many_ids(
|
||||
&all_subscriptions
|
||||
.iter()
|
||||
.map(|x| x.user_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for charge in all_charges {
|
||||
let Some(subscription) = all_subscriptions
|
||||
.iter_mut()
|
||||
.find(|x| Some(x.id) == charge.subscription_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if subscription.status == SubscriptionStatus::Unprovisioned {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(product_price) = subscription_prices
|
||||
.iter()
|
||||
.find(|x| x.id == subscription.price_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(product) = subscription_products
|
||||
.iter()
|
||||
.find(|x| x.id == product_price.product_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(user) =
|
||||
users.iter().find(|x| x.id == subscription.user_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let unprovisioned = match product.metadata {
|
||||
ProductMetadata::Midas => {
|
||||
let badges = user.badges - Badges::MIDAS;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET badges = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
badges.bits() as i64,
|
||||
user.id as DBUserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
ProductMetadata::Pyro { .. }
|
||||
| ProductMetadata::Medal { .. } => 'server: {
|
||||
let server_id = match &subscription.metadata {
|
||||
Some(SubscriptionMetadata::Pyro { id, region: _ }) => {
|
||||
id
|
||||
}
|
||||
Some(SubscriptionMetadata::Medal { id }) => id,
|
||||
_ => break 'server true,
|
||||
};
|
||||
|
||||
let res = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{}/modrinth/v0/servers/{}/suspend",
|
||||
dotenvy::var("ARCHON_URL")?,
|
||||
server_id
|
||||
))
|
||||
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
|
||||
.json(&serde_json::json!({
|
||||
"reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring {
|
||||
"cancelled"
|
||||
} else {
|
||||
"paymentfailed"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
warn!("Error suspending pyro server: {:?}", e);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if unprovisioned {
|
||||
subscription.status = SubscriptionStatus::Unprovisioned;
|
||||
subscription.upsert(&mut transaction).await?;
|
||||
}
|
||||
|
||||
clear_cache_users.push(user.id);
|
||||
}
|
||||
|
||||
crate::database::models::DBUser::clear_caches(
|
||||
&clear_cache_users
|
||||
.into_iter()
|
||||
.map(|x| (x, None))
|
||||
.collect::<Vec<_>>(),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
// If an offer redeemal has been processing for over 5 minutes, it should be set pending.
|
||||
UserRedeemal::update_stuck_5_minutes(&pool).await?;
|
||||
|
||||
// If an offer redeemal is pending, try processing it.
|
||||
// Try processing it.
|
||||
let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?;
|
||||
for redeemal in pending_redeemals {
|
||||
if let Err(error) =
|
||||
try_process_user_redeemal(&pool, &redis, redeemal).await
|
||||
{
|
||||
warn!(%error, "Failed to process a redeemal.")
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), ApiError>(())
|
||||
};
|
||||
|
||||
if let Err(e) = res.await {
|
||||
warn!("Error indexing subscriptions: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(error) = tax_charges_index_handle.await
|
||||
&& error.is_panic()
|
||||
{
|
||||
std::panic::resume_unwind(error.into_panic());
|
||||
}
|
||||
|
||||
info!("Done indexing subscriptions");
|
||||
}
|
||||
|
||||
/// Attempts to process a user redeemal.
|
||||
///
|
||||
/// Returns `Ok` if the entry has been succesfully processed, or will not be processed.
|
||||
pub async fn try_process_user_redeemal(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
mut user_redeemal: UserRedeemal,
|
||||
) -> Result<(), ApiError> {
|
||||
// Immediately update redeemal row
|
||||
user_redeemal.last_attempt = Some(Utc::now());
|
||||
user_redeemal.n_attempts += 1;
|
||||
user_redeemal.status = users_redeemals::Status::Processing;
|
||||
let updated = user_redeemal.update_status_if_pending(pool).await?;
|
||||
|
||||
if !updated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_id = user_redeemal.user_id;
|
||||
|
||||
// Find the Medal product's price & metadata
|
||||
|
||||
let mut medal_products =
|
||||
product_item::QueryProductWithPrices::list_by_product_type(
|
||||
pool, "medal",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(product_item::QueryProductWithPrices {
|
||||
id: _product_id,
|
||||
metadata,
|
||||
mut prices,
|
||||
unitary: _,
|
||||
name: _,
|
||||
}) = medal_products.pop()
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing Medal subscription product".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let ProductMetadata::Medal {
|
||||
cpu,
|
||||
ram,
|
||||
swap,
|
||||
storage,
|
||||
region,
|
||||
} = metadata
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing or incorrect metadata for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let Some(medal_price) = prices.pop() else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing price for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
let (price_duration, price_amount) = match medal_price.prices {
|
||||
Price::OneTime { price: _ } => {
|
||||
return Err(ApiError::Conflict(
|
||||
"Unexpected metadata for Medal subscription price".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Price::Recurring { intervals } => {
|
||||
let Some((price_duration, price_amount)) =
|
||||
intervals.into_iter().next()
|
||||
else {
|
||||
return Err(ApiError::Conflict(
|
||||
"Missing price interval for Medal subscription".to_owned(),
|
||||
));
|
||||
};
|
||||
|
||||
(price_duration, price_amount)
|
||||
}
|
||||
};
|
||||
|
||||
let price_id = medal_price.id;
|
||||
|
||||
// Get the user's username
|
||||
|
||||
let user = DBUser::get_id(user_id, pool, redis)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
// Send the provision request to Archon. On failure, the redeemal will be "stuck" processing,
|
||||
// and moved back to pending by `index_subscriptions`.
|
||||
|
||||
let archon_client = ArchonClient::from_env()?;
|
||||
let server_id = archon_client
|
||||
.create_server(&CreateServerRequest {
|
||||
user_id: to_base62(user_id.0 as u64),
|
||||
name: format!("{}'s Medal server", user.username),
|
||||
specs: Specs {
|
||||
memory_mb: ram,
|
||||
cpu,
|
||||
swap_mb: swap,
|
||||
storage_mb: storage,
|
||||
},
|
||||
source: crate::util::archon::Empty::default(),
|
||||
region,
|
||||
tags: vec!["medal".to_owned()],
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut txn = pool.begin().await?;
|
||||
|
||||
// Build a subscription using this price ID.
|
||||
let subscription = DBUserSubscription {
|
||||
id: generate_user_subscription_id(&mut txn).await?,
|
||||
user_id,
|
||||
price_id,
|
||||
interval: PriceDuration::FiveDays,
|
||||
created: Utc::now(),
|
||||
status: SubscriptionStatus::Provisioned,
|
||||
metadata: Some(SubscriptionMetadata::Medal {
|
||||
id: server_id.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
subscription.upsert(&mut txn).await?;
|
||||
|
||||
// Insert an expiring charge, `index_subscriptions` will unprovision the
|
||||
// subscription when expired.
|
||||
DBCharge {
|
||||
id: generate_charge_id(&mut txn).await?,
|
||||
user_id,
|
||||
price_id,
|
||||
amount: price_amount.into(),
|
||||
tax_amount: 0,
|
||||
tax_platform_id: None,
|
||||
currency_code: medal_price.currency_code,
|
||||
status: ChargeStatus::Expiring,
|
||||
due: Utc::now() + price_duration.duration(),
|
||||
last_attempt: None,
|
||||
type_: ChargeType::Subscription,
|
||||
subscription_id: Some(subscription.id),
|
||||
subscription_interval: Some(subscription.interval),
|
||||
payment_platform: PaymentPlatform::None,
|
||||
payment_platform_id: None,
|
||||
parent_charge_id: None,
|
||||
net: None,
|
||||
tax_last_updated: Some(Utc::now()),
|
||||
tax_drift_loss: Some(0),
|
||||
}
|
||||
.upsert(&mut txn)
|
||||
.await?;
|
||||
|
||||
// Update `users_redeemal`, mark subscription as redeemed.
|
||||
user_redeemal.status = users_redeemals::Status::Processed;
|
||||
user_redeemal.update(&mut *txn).await?;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_billing(
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
pool: PgPool,
|
||||
redis: RedisPool,
|
||||
) {
|
||||
info!("Indexing billing queue");
|
||||
let res = async {
|
||||
// If a charge has continuously failed for more than a month, it should be cancelled
|
||||
let charges_to_cancel = DBCharge::get_cancellable(&pool).await?;
|
||||
|
||||
for mut charge in charges_to_cancel {
|
||||
charge.status = ChargeStatus::Cancelled;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
charge.upsert(&mut transaction).await?;
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
// If a charge is open and due or has been attempted more than two days ago, it should be processed
|
||||
let charges_to_do = DBCharge::get_chargeable(&pool).await?;
|
||||
|
||||
let prices = product_item::DBProductPrice::get_many(
|
||||
&charges_to_do
|
||||
.iter()
|
||||
.map(|x| x.price_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let users = crate::database::models::DBUser::get_many_ids(
|
||||
&charges_to_do
|
||||
.iter()
|
||||
.map(|x| x.user_id)
|
||||
.collect::<HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
&pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for mut charge in charges_to_do {
|
||||
let Some(product_price) =
|
||||
prices.iter().find(|x| x.id == charge.price_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(user) = users.iter().find(|x| x.id == charge.user_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(currency) =
|
||||
Currency::from_str(&product_price.currency_code.to_lowercase())
|
||||
else {
|
||||
warn!(
|
||||
"Could not find currency for {}",
|
||||
product_price.currency_code
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let user = User::from_full(user.clone());
|
||||
|
||||
let result = create_or_update_payment_intent(
|
||||
&pool,
|
||||
&redis,
|
||||
&stripe_client,
|
||||
&anrok_client,
|
||||
PaymentBootstrapOptions {
|
||||
user: &user,
|
||||
payment_intent: None,
|
||||
payment_session: PaymentSession::AutomatedRenewal,
|
||||
attached_charge: AttachedCharge::UseExisting {
|
||||
charge: charge.clone(),
|
||||
},
|
||||
currency: CurrencyMode::Set(
|
||||
currency,
|
||||
),
|
||||
attach_payment_metadata: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
charge.status = ChargeStatus::Processing;
|
||||
charge.last_attempt = Some(Utc::now());
|
||||
|
||||
let mut failure = false;
|
||||
|
||||
match result {
|
||||
Ok(PaymentBootstrapResults {
|
||||
new_payment_intent,
|
||||
payment_method: _,
|
||||
price_id: _,
|
||||
subtotal,
|
||||
tax,
|
||||
}) => {
|
||||
if new_payment_intent.is_some() {
|
||||
// The PI will automatically be confirmed
|
||||
charge.amount = subtotal;
|
||||
charge.tax_amount = tax;
|
||||
charge.payment_platform = PaymentPlatform::Stripe;
|
||||
} else {
|
||||
error!("Payment bootstrap succeeded but no payment intent was created");
|
||||
failure = true;
|
||||
}
|
||||
}
|
||||
|
||||
Err(error) => {
|
||||
error!(%error, "Failed to bootstrap payment for renewal");
|
||||
failure = true;
|
||||
}
|
||||
};
|
||||
|
||||
if failure {
|
||||
charge.status = ChargeStatus::Failed;
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
charge.upsert(&mut transaction).await?;
|
||||
transaction.commit().await?;
|
||||
}
|
||||
|
||||
Ok::<(), ApiError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
warn!("Error indexing billing queue: {:?}", e);
|
||||
}
|
||||
|
||||
info!("Done indexing billing queue");
|
||||
}
|
||||
@@ -24,6 +24,20 @@ const VERIFYEMAIL_URL: &str = "verifyemail.url";
|
||||
const AUTHPROVIDER_NAME: &str = "authprovider.name";
|
||||
const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email";
|
||||
const BILLING_URL: &str = "billing.url";
|
||||
const SUBSCRIPTION_ID: &str = "subscription.id";
|
||||
|
||||
const TAXNOTIFICATION_OLD_AMOUNT: &str = "taxnotification.old_amount";
|
||||
const TAXNOTIFICATION_OLD_TAX_AMOUNT: &str = "taxnotification.old_tax_amount";
|
||||
const TAXNOTIFICATION_OLD_TOTAL_AMOUNT: &str =
|
||||
"taxnotification.old_total_amount";
|
||||
const TAXNOTIFICATION_NEW_AMOUNT: &str = "taxnotification.new_amount";
|
||||
const TAXNOTIFICATION_NEW_TAX_AMOUNT: &str = "taxnotification.new_tax_amount";
|
||||
const TAXNOTIFICATION_NEW_TOTAL_AMOUNT: &str =
|
||||
"taxnotification.new_total_amount";
|
||||
const TAXNOTIFICATION_BILLING_INTERVAL: &str =
|
||||
"taxnotification.billing_interval";
|
||||
const TAXNOTIFICATION_DUE: &str = "taxnotification.due";
|
||||
const TAXNOTIFICATION_SERVICE: &str = "taxnotification.service";
|
||||
|
||||
const PAYMENTFAILED_AMOUNT: &str = "paymentfailed.amount";
|
||||
const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service";
|
||||
@@ -545,12 +559,57 @@ async fn collect_template_variables(
|
||||
|
||||
map.insert(
|
||||
PAYOUTAVAILABLE_AMOUNT,
|
||||
format!("{:.2}", (amount * 100.0) as i64),
|
||||
format!("USD${:.2}", (amount * 100.0) as i64),
|
||||
);
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::TaxNotification {
|
||||
subscription_id,
|
||||
old_amount,
|
||||
old_tax_amount,
|
||||
new_amount,
|
||||
new_tax_amount,
|
||||
billing_interval,
|
||||
currency,
|
||||
due,
|
||||
service,
|
||||
} => {
|
||||
map.insert(
|
||||
TAXNOTIFICATION_OLD_AMOUNT,
|
||||
fmt_money(*old_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_OLD_TAX_AMOUNT,
|
||||
fmt_money(*old_tax_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_OLD_TOTAL_AMOUNT,
|
||||
fmt_money(*old_amount + *old_tax_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_NEW_AMOUNT,
|
||||
fmt_money(*new_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_NEW_TAX_AMOUNT,
|
||||
fmt_money(*new_tax_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_NEW_TOTAL_AMOUNT,
|
||||
fmt_money(*new_amount + *new_tax_amount, currency),
|
||||
);
|
||||
map.insert(
|
||||
TAXNOTIFICATION_BILLING_INTERVAL,
|
||||
billing_interval.as_str().to_owned(),
|
||||
);
|
||||
map.insert(TAXNOTIFICATION_DUE, date_human_readable(*due));
|
||||
map.insert(TAXNOTIFICATION_SERVICE, service.clone());
|
||||
map.insert(SUBSCRIPTION_ID, to_base62(subscription_id.0));
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
NotificationBody::ProjectUpdate { .. }
|
||||
| NotificationBody::ModeratorMessage { .. }
|
||||
| NotificationBody::LegacyMarkdown { .. }
|
||||
@@ -561,3 +620,11 @@ async fn collect_template_variables(
|
||||
fn date_human_readable(date: chrono::DateTime<chrono::Utc>) -> String {
|
||||
date.format("%B %d, %Y").to_string()
|
||||
}
|
||||
|
||||
fn fmt_money(amount: i64, currency: &str) -> String {
|
||||
rusty_money::Money::from_minor(
|
||||
amount,
|
||||
rusty_money::iso::find(currency).unwrap_or(rusty_money::iso::USD),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod analytics;
|
||||
pub mod billing;
|
||||
pub mod email;
|
||||
pub mod maxmind;
|
||||
pub mod moderation;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
798
apps/labrinth/src/routes/internal/billing/payments.rs
Normal file
798
apps/labrinth/src/routes/internal/billing/payments.rs
Normal file
@@ -0,0 +1,798 @@
|
||||
use crate::database::models::charge_item::DBCharge;
|
||||
use crate::database::models::{
|
||||
generate_charge_id, generate_user_subscription_id, product_item,
|
||||
products_tax_identifier_item, user_subscription_item,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::*;
|
||||
use crate::models::v3::billing::SubscriptionStatus;
|
||||
use crate::models::v3::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::anrok;
|
||||
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::*;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use stripe::{
|
||||
self, CreateCustomer, CreatePaymentIntent, Currency, CustomerId,
|
||||
PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethod,
|
||||
PaymentMethodId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ChargeRequestType, ChargeType, PaymentRequestMetadata, PaymentRequestType,
|
||||
Price, PriceDuration,
|
||||
};
|
||||
|
||||
const DEFAULT_USER_COUNTRY: &str = "US";
|
||||
|
||||
pub const MODRINTH_SUBSCRIPTION_ID: &str = "modrinth_subscription_id";
|
||||
pub const MODRINTH_PRICE_ID: &str = "modrinth_price_id";
|
||||
pub const MODRINTH_SUBSCRIPTION_INTERVAL: &str =
|
||||
"modrinth_subscription_interval";
|
||||
pub const MODRINTH_CHARGE_TYPE: &str = "modrinth_charge_type";
|
||||
pub const MODRINTH_NEW_REGION: &str = "modrinth_new_region";
|
||||
pub const MODRINTH_USER_ID: &str = "modrinth_user_id";
|
||||
pub const MODRINTH_CHARGE_ID: &str = "modrinth_charge_id";
|
||||
pub const MODRINTH_TAX_AMOUNT: &str = "modrinth_tax_amount";
|
||||
pub const MODRINTH_PAYMENT_METADATA: &str = "modrinth_payment_metadata";
|
||||
|
||||
pub enum AttachedCharge {
|
||||
/// Create a proration charge.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
Proration {
|
||||
next_product_id: ProductId,
|
||||
next_interval: PriceDuration,
|
||||
current_subscription: UserSubscriptionId,
|
||||
amount: i64,
|
||||
},
|
||||
/// Create a promotion charge.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
Promotion {
|
||||
product_id: ProductId,
|
||||
interval: PriceDuration,
|
||||
current_subscription: UserSubscriptionId,
|
||||
new_region: String,
|
||||
},
|
||||
/// Base the payment intent amount and tax on the product's price at this interval,
|
||||
/// but don't actually create a charge item until the payment intent is confirmed.
|
||||
///
|
||||
/// The amount will be based on the product's price at this interval,
|
||||
/// and tax calculated based on the payment method.
|
||||
///
|
||||
/// This should be accompanied by an interactive payment session.
|
||||
BaseUpon {
|
||||
product_id: ProductId,
|
||||
interval: Option<PriceDuration>,
|
||||
},
|
||||
/// Use an existing charge to base the payment intent upon.
|
||||
///
|
||||
/// This can be used in the case of resubscription flows. The amount from this
|
||||
/// charge will be used, but the tax will be recalculated and the charge updated.
|
||||
///
|
||||
/// The charge's status will NOT be updated - it is the caller's responsability to
|
||||
/// update the charge's status on failure or success.
|
||||
///
|
||||
/// This may be accompanied by an automated payment session.
|
||||
UseExisting { charge: DBCharge },
|
||||
}
|
||||
|
||||
impl AttachedCharge {
|
||||
pub fn as_charge(&self) -> Option<&DBCharge> {
|
||||
if let AttachedCharge::UseExisting { charge } = self {
|
||||
Some(charge)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_charge_request_type(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
charge_request_type: ChargeRequestType,
|
||||
) -> Result<Self, ApiError> {
|
||||
Ok(match charge_request_type {
|
||||
ChargeRequestType::Existing { id } => AttachedCharge::UseExisting {
|
||||
charge: DBCharge::get(id.into(), exec).await?.ok_or_else(
|
||||
|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find charge".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
},
|
||||
ChargeRequestType::New {
|
||||
product_id,
|
||||
interval,
|
||||
} => AttachedCharge::BaseUpon {
|
||||
product_id,
|
||||
interval,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PaymentSession {
|
||||
Interactive {
|
||||
payment_request_type: PaymentRequestType,
|
||||
},
|
||||
AutomatedRenewal,
|
||||
}
|
||||
|
||||
impl PaymentSession {
|
||||
pub fn set_payment_intent_session_options(
|
||||
&self,
|
||||
intent: &mut CreatePaymentIntent,
|
||||
) {
|
||||
if matches!(self, PaymentSession::AutomatedRenewal) {
|
||||
intent.off_session = Some(PaymentIntentOffSession::Exists(true)); // Mark as the customer isn't able to perform manual verification/isn't on-session
|
||||
intent.confirm = Some(true); // Immediately confirm the PI
|
||||
} else {
|
||||
intent.off_session = None;
|
||||
intent.setup_future_usage =
|
||||
Some(PaymentIntentSetupFutureUsage::OffSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CurrencyMode {
|
||||
Set(Currency),
|
||||
Infer,
|
||||
}
|
||||
|
||||
pub struct PaymentBootstrapOptions<'a> {
|
||||
pub user: &'a User,
|
||||
/// Update this payment intent instead of creating a new intent.
|
||||
pub payment_intent: Option<stripe::PaymentIntentId>,
|
||||
/// The status of the current payment session. This is used to derive the payment
|
||||
/// method as well as set the appropriate parameters on the payment intent.
|
||||
///
|
||||
/// For interactive payment flows, a `PaymentRequestType` can be attached, we can be
|
||||
/// either an existing PaymentMethodId for existing payment methods, or a ConfirmationToken
|
||||
/// (ctoken) for new payment methods.
|
||||
///
|
||||
/// For automated subscription renewal flows, use the `AutomatedRenewal` variant to
|
||||
/// select the default payment method from the Stripe customer.
|
||||
///
|
||||
/// Taxes will always be collected.
|
||||
///
|
||||
/// Note the charge will NOT be updated. It is the caller's responsability to update the charge
|
||||
/// on success or failure.
|
||||
pub payment_session: PaymentSession,
|
||||
/// The charge the payment intent on should be based upon.
|
||||
pub attached_charge: AttachedCharge,
|
||||
/// The currency used for the payment amount.
|
||||
pub currency: CurrencyMode,
|
||||
/// Some products have additional provisioning metadata that should be attached to the payment
|
||||
/// intent.
|
||||
pub attach_payment_metadata: Option<PaymentRequestMetadata>,
|
||||
}
|
||||
|
||||
pub struct PaymentBootstrapResults {
|
||||
pub new_payment_intent: Option<stripe::PaymentIntent>,
|
||||
pub payment_method: PaymentMethod,
|
||||
pub price_id: ProductPriceId,
|
||||
pub subtotal: i64,
|
||||
pub tax: i64,
|
||||
}
|
||||
|
||||
/// Updates a PaymentIntent or creates a new one, recalculating tax information and
|
||||
/// setting metadata fields based on the specified payment request and session options.
|
||||
///
|
||||
/// # Important notes
|
||||
///
|
||||
/// - This function does not perform any database writes. It is the caller's responsability to, for
|
||||
/// example, update the charge's status on success or failure, or update the charge's tax amount,
|
||||
/// tax eligibility or payment and tax platform IDs.
|
||||
/// - You may not update or create a payment intent for an off-session payment flow without
|
||||
/// attaching a charge.
|
||||
pub async fn create_or_update_payment_intent(
|
||||
pg: &PgPool,
|
||||
redis: &RedisPool,
|
||||
stripe_client: &stripe::Client,
|
||||
anrok_client: &anrok::Client,
|
||||
PaymentBootstrapOptions {
|
||||
user,
|
||||
payment_intent: existing_payment_intent,
|
||||
payment_session,
|
||||
attached_charge,
|
||||
currency: currency_mode,
|
||||
attach_payment_metadata,
|
||||
}: PaymentBootstrapOptions<'_>,
|
||||
) -> Result<PaymentBootstrapResults, ApiError> {
|
||||
let customer_id = get_or_create_customer(
|
||||
user.id,
|
||||
user.stripe_customer_id.as_deref(),
|
||||
user.email.as_deref(),
|
||||
stripe_client,
|
||||
pg,
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let payment_method = match &payment_session {
|
||||
PaymentSession::Interactive {
|
||||
payment_request_type: PaymentRequestType::PaymentMethod { id },
|
||||
} => {
|
||||
let payment_method_id =
|
||||
PaymentMethodId::from_str(id).map_err(|_| {
|
||||
ApiError::InvalidInput(
|
||||
"Invalid payment method id".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
PaymentMethod::retrieve(stripe_client, &payment_method_id, &[])
|
||||
.await?
|
||||
}
|
||||
PaymentSession::Interactive {
|
||||
payment_request_type:
|
||||
PaymentRequestType::ConfirmationToken { token },
|
||||
} => {
|
||||
#[derive(Deserialize)]
|
||||
struct ConfirmationToken {
|
||||
payment_method_preview: Option<PaymentMethod>,
|
||||
}
|
||||
|
||||
let mut confirmation: serde_json::Value = stripe_client
|
||||
.get(&format!("confirmation_tokens/{token}"))
|
||||
.await?;
|
||||
|
||||
// We patch the JSONs to support the PaymentMethod struct
|
||||
let p: json_patch::Patch = serde_json::from_value(serde_json::json!([
|
||||
{ "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" },
|
||||
{ "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 },
|
||||
{ "op": "add", "path": "/payment_method_preview/livemode", "value": false }
|
||||
])).unwrap();
|
||||
json_patch::patch(&mut confirmation, &p).unwrap();
|
||||
|
||||
let confirmation: ConfirmationToken =
|
||||
serde_json::from_value(confirmation)?;
|
||||
|
||||
confirmation.payment_method_preview.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Confirmation token is missing payment method!".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
PaymentSession::AutomatedRenewal => {
|
||||
if attached_charge.as_charge().is_none() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Missing attached charge for automated renewal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let customer = stripe::Customer::retrieve(
|
||||
stripe_client,
|
||||
&customer_id,
|
||||
&["invoice_settings.default_payment_method"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
customer
|
||||
.invoice_settings
|
||||
.and_then(|x| {
|
||||
x.default_payment_method.and_then(|x| x.into_object())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Customer has no default payment method!".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let user_country = payment_method
|
||||
.billing_details
|
||||
.address
|
||||
.as_ref()
|
||||
.and_then(|x| x.country.as_deref())
|
||||
.unwrap_or(DEFAULT_USER_COUNTRY);
|
||||
|
||||
let inferred_stripe_currency = match currency_mode {
|
||||
CurrencyMode::Set(currency) => currency,
|
||||
CurrencyMode::Infer => infer_currency_code(user_country)
|
||||
.to_lowercase()
|
||||
.parse::<Currency>()
|
||||
.map_err(|_| {
|
||||
ApiError::InvalidInput("Invalid currency code".to_string())
|
||||
})?,
|
||||
};
|
||||
|
||||
let charge_data = match attached_charge {
|
||||
AttachedCharge::UseExisting { ref charge } => ChargeData {
|
||||
amount: charge.amount,
|
||||
currency_code: charge.currency_code.clone(),
|
||||
interval: charge.subscription_interval,
|
||||
price_id: charge.price_id.into(),
|
||||
charge_type: charge.type_,
|
||||
},
|
||||
AttachedCharge::Proration {
|
||||
amount,
|
||||
next_product_id,
|
||||
next_interval,
|
||||
current_subscription: _,
|
||||
} => {
|
||||
// Use the same data as we would use when basing the charge data on
|
||||
// a product/interval pair, except override the amount and charge type
|
||||
// to the proration values.
|
||||
//
|
||||
// Then, the tax will be based on the next product, and the metadata
|
||||
// will be inserted as is desired for proration charges, except
|
||||
// the actual payment intent amount will be the proration amount.
|
||||
|
||||
let mut charge_data = derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
next_product_id,
|
||||
Some(next_interval),
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?;
|
||||
|
||||
charge_data.amount = amount;
|
||||
charge_data.charge_type = ChargeType::Proration;
|
||||
charge_data
|
||||
}
|
||||
AttachedCharge::Promotion {
|
||||
product_id,
|
||||
interval,
|
||||
current_subscription: _,
|
||||
new_region: _,
|
||||
} => {
|
||||
derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
product_id,
|
||||
Some(interval),
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
AttachedCharge::BaseUpon {
|
||||
product_id,
|
||||
interval,
|
||||
} => {
|
||||
derive_charge_data_from_product_selector(
|
||||
pg,
|
||||
user.id,
|
||||
product_id,
|
||||
interval,
|
||||
inferred_stripe_currency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
// Create an ephemeral transaction to calculate the tax amount if needed
|
||||
|
||||
let tax_amount = 'tax: {
|
||||
// If a charge is attached, we must use the tax amount noted on the charge
|
||||
// as the tax amount.
|
||||
//
|
||||
// Note: if we supported interactive payments of existing charges, we may
|
||||
// want to update the charge's tax amount immediately here.
|
||||
if let Some(c) = attached_charge.as_charge() {
|
||||
break 'tax c.tax_amount;
|
||||
}
|
||||
|
||||
let product_info =
|
||||
products_tax_identifier_item::product_info_by_product_price_id(
|
||||
charge_data.price_id.into(),
|
||||
pg,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Missing product tax identifier for charge to continue"
|
||||
.to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let address =
|
||||
payment_method.billing_details.address.clone().ok_or_else(
|
||||
|| {
|
||||
ApiError::InvalidInput(
|
||||
"Missing billing details from payment method to continue"
|
||||
.to_owned(),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
let ephemeral_invoice = anrok_client
|
||||
.create_ephemeral_txn(&anrok::TransactionFields {
|
||||
customer_address: anrok::Address::from_stripe_address(&address),
|
||||
currency_code: charge_data.currency_code.clone(),
|
||||
accounting_time: chrono::Utc::now(),
|
||||
accounting_time_zone: anrok::AccountingTimeZone::Utc,
|
||||
line_items: vec![anrok::LineItem::new(
|
||||
product_info.tax_identifier.tax_processor_id,
|
||||
charge_data.amount,
|
||||
)],
|
||||
})
|
||||
.await?;
|
||||
|
||||
ephemeral_invoice.tax_amount_to_collect
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user.id.0));
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_TYPE.to_owned(),
|
||||
charge_data.charge_type.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(MODRINTH_TAX_AMOUNT.to_owned(), tax_amount.to_string());
|
||||
|
||||
if let Some(payment_metadata) = attach_payment_metadata {
|
||||
metadata.insert(
|
||||
MODRINTH_PAYMENT_METADATA.to_owned(),
|
||||
serde_json::to_string(&payment_metadata)?,
|
||||
);
|
||||
}
|
||||
|
||||
if let AttachedCharge::UseExisting { charge } = attached_charge {
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge.id.0 as u64),
|
||||
);
|
||||
|
||||
// These are only used to post-create the charge in the stripe webhook, so
|
||||
// unset them.
|
||||
metadata.insert(MODRINTH_PRICE_ID.to_owned(), String::new());
|
||||
metadata
|
||||
.insert(MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), String::new());
|
||||
metadata.insert(MODRINTH_SUBSCRIPTION_ID.to_owned(), String::new());
|
||||
} else if let AttachedCharge::Proration {
|
||||
amount: _,
|
||||
next_product_id: _,
|
||||
next_interval,
|
||||
current_subscription,
|
||||
} = attached_charge
|
||||
{
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
next_interval.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
current_subscription.to_string(),
|
||||
);
|
||||
} else if let AttachedCharge::Promotion {
|
||||
product_id: _,
|
||||
interval,
|
||||
current_subscription,
|
||||
new_region,
|
||||
} = attached_charge
|
||||
{
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
interval.as_str().to_owned(),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
current_subscription.to_string(),
|
||||
);
|
||||
metadata.insert(MODRINTH_NEW_REGION.to_owned(), new_region);
|
||||
} else {
|
||||
let mut transaction = pg.begin().await?;
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
let subscription_id =
|
||||
generate_user_subscription_id(&mut transaction).await?;
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_CHARGE_ID.to_owned(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_ID.to_owned(),
|
||||
to_base62(subscription_id.0 as u64),
|
||||
);
|
||||
|
||||
metadata.insert(
|
||||
MODRINTH_PRICE_ID.to_owned(),
|
||||
charge_data.price_id.to_string(),
|
||||
);
|
||||
|
||||
if let Some(interval) = charge_data.interval {
|
||||
metadata.insert(
|
||||
MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(),
|
||||
interval.as_str().to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(payment_intent_id) = existing_payment_intent {
|
||||
let update_payment_intent = stripe::UpdatePaymentIntent {
|
||||
amount: Some(charge_data.amount + tax_amount),
|
||||
currency: Some(inferred_stripe_currency),
|
||||
customer: Some(customer_id),
|
||||
metadata: Some(metadata),
|
||||
payment_method: Some(payment_method.id.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
stripe::PaymentIntent::update(
|
||||
stripe_client,
|
||||
&payment_intent_id,
|
||||
update_payment_intent,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(PaymentBootstrapResults {
|
||||
new_payment_intent: None,
|
||||
payment_method,
|
||||
price_id: charge_data.price_id,
|
||||
subtotal: charge_data.amount,
|
||||
tax: tax_amount,
|
||||
})
|
||||
} else {
|
||||
let mut intent = CreatePaymentIntent::new(
|
||||
charge_data.amount + tax_amount,
|
||||
inferred_stripe_currency,
|
||||
);
|
||||
|
||||
intent.customer = Some(customer_id);
|
||||
intent.metadata = Some(metadata);
|
||||
intent.receipt_email = user.email.as_deref();
|
||||
intent.payment_method = Some(payment_method.id.clone());
|
||||
|
||||
payment_session.set_payment_intent_session_options(&mut intent);
|
||||
|
||||
let payment_intent =
|
||||
stripe::PaymentIntent::create(stripe_client, intent).await?;
|
||||
|
||||
Ok(PaymentBootstrapResults {
|
||||
new_payment_intent: Some(payment_intent),
|
||||
payment_method,
|
||||
price_id: charge_data.price_id,
|
||||
subtotal: charge_data.amount,
|
||||
tax: tax_amount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_customer(
|
||||
user_id: ariadne::ids::UserId,
|
||||
stripe_customer_id: Option<&str>,
|
||||
user_email: Option<&str>,
|
||||
client: &stripe::Client,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<CustomerId, ApiError> {
|
||||
if let Some(customer_id) =
|
||||
stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok())
|
||||
{
|
||||
Ok(customer_id)
|
||||
} else {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user_id.0));
|
||||
|
||||
let customer = stripe::Customer::create(
|
||||
client,
|
||||
CreateCustomer {
|
||||
email: user_email,
|
||||
metadata: Some(metadata),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET stripe_customer_id = $1
|
||||
WHERE id = $2
|
||||
",
|
||||
customer.id.as_str(),
|
||||
user_id.0 as i64
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
crate::database::models::user_item::DBUser::clear_caches(
|
||||
&[(user_id.into(), None)],
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(customer.id)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn infer_currency_code(country: &str) -> String {
|
||||
match country {
|
||||
"US" => "USD",
|
||||
"GB" => "GBP",
|
||||
"EU" => "EUR",
|
||||
"AT" => "EUR",
|
||||
"BE" => "EUR",
|
||||
"CY" => "EUR",
|
||||
"EE" => "EUR",
|
||||
"ES" => "EUR",
|
||||
"FI" => "EUR",
|
||||
"FR" => "EUR",
|
||||
"DE" => "EUR",
|
||||
"GR" => "EUR",
|
||||
"IE" => "EUR",
|
||||
"IT" => "EUR",
|
||||
"LV" => "EUR",
|
||||
"LT" => "EUR",
|
||||
"LU" => "EUR",
|
||||
"MT" => "EUR",
|
||||
"NL" => "EUR",
|
||||
"PT" => "EUR",
|
||||
"SK" => "EUR",
|
||||
"SI" => "EUR",
|
||||
"RU" => "RUB",
|
||||
"BR" => "BRL",
|
||||
"JP" => "JPY",
|
||||
"ID" => "IDR",
|
||||
"MY" => "MYR",
|
||||
"PH" => "PHP",
|
||||
"TH" => "THB",
|
||||
"VN" => "VND",
|
||||
"KR" => "KRW",
|
||||
"TR" => "TRY",
|
||||
"UA" => "UAH",
|
||||
"MX" => "MXN",
|
||||
"CA" => "CAD",
|
||||
"NZ" => "NZD",
|
||||
"NO" => "NOK",
|
||||
"PL" => "PLN",
|
||||
"CH" => "CHF",
|
||||
"LI" => "CHF",
|
||||
"IN" => "INR",
|
||||
"CL" => "CLP",
|
||||
"PE" => "PEN",
|
||||
"CO" => "COP",
|
||||
"ZA" => "ZAR",
|
||||
"HK" => "HKD",
|
||||
"AR" => "ARS",
|
||||
"KZ" => "KZT",
|
||||
"UY" => "UYU",
|
||||
"CN" => "CNY",
|
||||
"AU" => "AUD",
|
||||
"TW" => "TWD",
|
||||
"SA" => "SAR",
|
||||
"QA" => "QAR",
|
||||
"SG" => "SGD",
|
||||
_ => "USD",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
struct ChargeData {
|
||||
pub amount: i64,
|
||||
pub currency_code: String,
|
||||
pub interval: Option<PriceDuration>,
|
||||
pub price_id: ProductPriceId,
|
||||
pub charge_type: ChargeType,
|
||||
}
|
||||
|
||||
async fn derive_charge_data_from_product_selector(
|
||||
pool: &PgPool,
|
||||
user_id: UserId,
|
||||
product_id: ProductId,
|
||||
interval: Option<PriceDuration>,
|
||||
stripe_currency: Currency,
|
||||
) -> Result<ChargeData, ApiError> {
|
||||
let recommended_currency_code = stripe_currency.to_string().to_uppercase();
|
||||
|
||||
let product = product_item::DBProduct::get(product_id.into(), pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Specified product could not be found!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut product_prices =
|
||||
product_item::DBProductPrice::get_all_public_product_prices(
|
||||
product.id, pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let price_item = if let Some(pos) = product_prices
|
||||
.iter()
|
||||
.position(|x| x.currency_code == recommended_currency_code)
|
||||
{
|
||||
product_prices.remove(pos)
|
||||
} else if let Some(pos) =
|
||||
product_prices.iter().position(|x| x.currency_code == "USD")
|
||||
{
|
||||
product_prices.remove(pos)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let price = match price_item.prices {
|
||||
Price::OneTime { price } => price,
|
||||
Price::Recurring { ref intervals } => {
|
||||
let interval = interval.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
*intervals.get(&interval).ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"Could not find a valid price for the user's country"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if let Price::Recurring { .. } = price_item.prices
|
||||
&& product.unitary
|
||||
{
|
||||
let user_subscriptions =
|
||||
user_subscription_item::DBUserSubscription::get_all_user(
|
||||
user_id.into(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_products = product_item::DBProductPrice::get_many(
|
||||
&user_subscriptions
|
||||
.iter()
|
||||
.filter(|x| x.status == SubscriptionStatus::Provisioned)
|
||||
.map(|x| x.price_id)
|
||||
.collect::<Vec<_>>(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if user_products
|
||||
.into_iter()
|
||||
.any(|x| x.product_id == product.id)
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You are already subscribed to this product!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChargeData {
|
||||
amount: price as i64,
|
||||
currency_code: price_item.currency_code.clone(),
|
||||
interval,
|
||||
price_id: price_item.id.into(),
|
||||
charge_type: if let Price::Recurring { .. } = price_item.prices {
|
||||
ChargeType::Subscription
|
||||
} else {
|
||||
ChargeType::OneTime
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -9,8 +9,8 @@ use crate::database::models::users_redeemals::{
|
||||
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::queue::billing::try_process_user_redeemal;
|
||||
use crate::routes::ApiError;
|
||||
use crate::routes::internal::billing::try_process_user_redeemal;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
|
||||
pub mod statuses;
|
||||
|
||||
pub use super::ApiError;
|
||||
|
||||
@@ -143,6 +143,8 @@ pub enum ApiError {
|
||||
Conflict(String),
|
||||
#[error("External tax compliance API Error")]
|
||||
TaxComplianceApi,
|
||||
#[error(transparent)]
|
||||
TaxProcessor(#[from] crate::util::anrok::AnrokError),
|
||||
#[error(
|
||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||
)]
|
||||
@@ -184,6 +186,7 @@ impl ApiError {
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
ApiError::Stripe(..) => "stripe_error",
|
||||
ApiError::TaxProcessor(..) => "tax_processor_error",
|
||||
ApiError::Slack(..) => "slack_error",
|
||||
},
|
||||
description: self.to_string(),
|
||||
@@ -223,6 +226,7 @@ impl actix_web::ResponseError for ApiError {
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
293
apps/labrinth/src/util/anrok.rs
Normal file
293
apps/labrinth/src/util/anrok.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use rand::Rng;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{DisplayFromStr, serde_as};
|
||||
use thiserror::Error;
|
||||
use tracing::trace;
|
||||
|
||||
pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String {
|
||||
format!("stripe:charge:{pi}")
|
||||
}
|
||||
|
||||
pub fn transaction_id_stripe_pyr(charge: &stripe::RefundId) -> String {
|
||||
format!("stripe:refund:{charge}")
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvoiceResponse {
|
||||
pub tax_amount_to_collect: i64,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct EmptyResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Address {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub country: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub line1: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub postal_code: Option<String>,
|
||||
}
|
||||
|
||||
impl Address {
|
||||
pub fn from_stripe_address(address: &stripe::Address) -> Self {
|
||||
Self {
|
||||
country: address.country.clone(),
|
||||
line1: address.line1.clone(),
|
||||
city: address.city.clone(),
|
||||
region: address.state.clone(),
|
||||
postal_code: address.postal_code.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub id: Option<String>,
|
||||
pub product_external_id: String,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub quantity: u32,
|
||||
#[serde(rename = "amount")]
|
||||
pub amount_in_smallest_denominations: i64,
|
||||
pub is_tax_included_in_amount: bool,
|
||||
}
|
||||
|
||||
impl LineItem {
|
||||
pub const fn new(
|
||||
product_external_id: String,
|
||||
amount_in_smallest_denominations: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
product_external_id,
|
||||
quantity: 1,
|
||||
amount_in_smallest_denominations,
|
||||
is_tax_included_in_amount: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn new_including_tax_amount(
|
||||
product_external_id: String,
|
||||
amount_in_smallest_denominations: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
product_external_id,
|
||||
quantity: 1,
|
||||
amount_in_smallest_denominations,
|
||||
is_tax_included_in_amount: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AccountingTimeZone {
|
||||
#[default]
|
||||
#[serde(rename = "UTC")]
|
||||
Utc,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransactionFields {
|
||||
pub customer_address: Address,
|
||||
pub currency_code: String,
|
||||
pub accounting_time: DateTime<Utc>,
|
||||
pub accounting_time_zone: AccountingTimeZone,
|
||||
pub line_items: Vec<LineItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Transaction {
|
||||
#[serde(flatten)]
|
||||
pub fields: TransactionFields,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AnrokError {
|
||||
#[error("Anrok API Error: {0}")]
|
||||
Conflict(String),
|
||||
#[error("Anrok API Error: Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("Rate limit exceeded using Anrok API")]
|
||||
RateLimit,
|
||||
#[error("Anrok API error: {0}")]
|
||||
Other(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Client {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_env() -> Result<Self, dotenvy::Error> {
|
||||
let api_key = dotenvy::var("ANROK_API_KEY")?;
|
||||
let api_url = dotenvy::var("ANROK_API_URL")?
|
||||
.trim_start_matches('/')
|
||||
.to_owned();
|
||||
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Modrinth")
|
||||
.build()
|
||||
.expect("AnrokClient to build"),
|
||||
api_key,
|
||||
api_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_ephemeral_txn(
|
||||
&self,
|
||||
body: &TransactionFields,
|
||||
) -> Result<InvoiceResponse, AnrokError> {
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
"/v1/seller/transactions/createEphemeral",
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_or_update_txn(
|
||||
&self,
|
||||
body: &Transaction,
|
||||
) -> Result<InvoiceResponse, AnrokError> {
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
"/v1/seller/transactions/createOrUpdate",
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn void_txn(
|
||||
&self,
|
||||
id: String,
|
||||
version: i32,
|
||||
) -> Result<EmptyResponse, AnrokError> {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Body {
|
||||
transaction_expected_version: i32,
|
||||
}
|
||||
|
||||
self.make_request(
|
||||
Method::POST,
|
||||
&format!("/v1/seller/transactions/id:{id}/void"),
|
||||
Some(&Body {
|
||||
transaction_expected_version: version,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn make_request<T: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<R, AnrokError> {
|
||||
let mut n = 0u64;
|
||||
|
||||
loop {
|
||||
if n >= 3 {
|
||||
return Err(AnrokError::RateLimit);
|
||||
}
|
||||
|
||||
match self.make_request_inner(method.clone(), path, body).await {
|
||||
Err(AnrokError::RateLimit) => {
|
||||
n += 1;
|
||||
// 1000 + ~500, 2000 + ~1000, 5000 + ~2500
|
||||
let base = (n - 1).pow(2) * 1000 + 1000;
|
||||
let random = rand::thread_rng().gen_range(0..(base / 2));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
base + random,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_request_inner<T: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&T>,
|
||||
) -> Result<R, AnrokError> {
|
||||
let then = std::time::Instant::now();
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConflictResponse {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
}
|
||||
|
||||
let mut builder = self
|
||||
.client
|
||||
.request(method.clone(), format!("{}/{}", self.api_url, path))
|
||||
.bearer_auth(&self.api_key);
|
||||
|
||||
if let Some(body) = body {
|
||||
builder = builder.json(&body);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
|
||||
trace!(
|
||||
http.status = %response.status().as_u16(),
|
||||
http.method = %method,
|
||||
http.path = %path,
|
||||
duration = format!("{}ms", then.elapsed().as_millis()),
|
||||
"Received Anrok response",
|
||||
);
|
||||
|
||||
match response.status() {
|
||||
StatusCode::CONFLICT => {
|
||||
return Err(AnrokError::Conflict(
|
||||
response.json::<ConflictResponse>().await?.type_,
|
||||
));
|
||||
}
|
||||
|
||||
StatusCode::BAD_REQUEST => {
|
||||
return Err(AnrokError::BadRequest(
|
||||
response.json::<String>().await.unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(AnrokError::RateLimit),
|
||||
|
||||
s if !s.is_success() => {
|
||||
if let Err(error) = response.error_for_status_ref() {
|
||||
return Err(AnrokError::Other(error));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let body = response.json::<R>().await?;
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod actix;
|
||||
pub mod anrok;
|
||||
pub mod archon;
|
||||
pub mod avalara1099;
|
||||
pub mod bitflag;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use labrinth::queue::email::EmailQueue;
|
||||
use labrinth::util::anrok;
|
||||
use labrinth::{LabrinthConfig, file_hosting, queue};
|
||||
use labrinth::{check_env_vars, clickhouse};
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +41,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
let stripe_client =
|
||||
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
||||
|
||||
let anrok_client = anrok::Client::from_env().unwrap();
|
||||
let email_queue =
|
||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||
|
||||
@@ -52,6 +54,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
file_host.clone(),
|
||||
maxmind_reader,
|
||||
stripe_client,
|
||||
anrok_client,
|
||||
email_queue,
|
||||
false,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user