You've already forked AstralRinth
forked from xxxOFFxxx/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
|
COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||||
|
|
||||||
|
ANROK_API_KEY=none
|
||||||
|
ANROK_API_URL=none
|
||||||
|
|
||||||
ARCHON_URL=none
|
ARCHON_URL=none
|
||||||
|
|||||||
@@ -139,4 +139,7 @@ AVALARA_1099_COMPANY_ID=207337084
|
|||||||
|
|
||||||
COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||||
|
|
||||||
|
ANROK_API_KEY=none
|
||||||
|
ANROK_API_URL=none
|
||||||
|
|
||||||
ARCHON_URL=none
|
ARCHON_URL=none
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "unitary",
|
"name": "unitary",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -27,8 +32,9 @@
|
|||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
|
"hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
|
"hash": "6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "unitary",
|
"name": "unitary",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -27,8 +32,9 @@
|
|||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362"
|
"hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -55,28 +55,48 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"name": "subscription_interval?",
|
"name": "tax_amount",
|
||||||
"type_info": "Text"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"name": "payment_platform",
|
"name": "tax_platform_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"name": "payment_platform_id?",
|
"name": "subscription_interval?",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 13,
|
"ordinal": 13,
|
||||||
|
"name": "payment_platform",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 14,
|
||||||
|
"name": "payment_platform_id?",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 15,
|
||||||
"name": "parent_charge_id?",
|
"name": "parent_charge_id?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 14,
|
"ordinal": 16,
|
||||||
"name": "net?",
|
"name": "net?",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 17,
|
||||||
|
"name": "tax_last_updated?",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 18,
|
||||||
|
"name": "tax_drift_loss?",
|
||||||
|
"type_info": "Int8"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -95,12 +115,16 @@
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
|
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "unitary",
|
"name": "unitary",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "name",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -27,8 +32,9 @@
|
|||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
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::database::redis::RedisPool;
|
||||||
|
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||||
use crate::queue::email::EmailQueue;
|
use crate::queue::email::EmailQueue;
|
||||||
use crate::queue::payouts::{
|
use crate::queue::payouts::{
|
||||||
PayoutsQueue, index_payouts_notifications,
|
PayoutsQueue, index_payouts_notifications,
|
||||||
insert_bank_balances_and_webhook, process_payout,
|
insert_bank_balances_and_webhook, process_payout,
|
||||||
};
|
};
|
||||||
use crate::search::indexing::index_projects;
|
use crate::search::indexing::index_projects;
|
||||||
|
use crate::util::anrok;
|
||||||
use crate::{database, search};
|
use crate::{database, search};
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use sqlx::Postgres;
|
use sqlx::Postgres;
|
||||||
@@ -24,6 +26,7 @@ pub enum BackgroundTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BackgroundTask {
|
impl BackgroundTask {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
self,
|
self,
|
||||||
pool: sqlx::Pool<Postgres>,
|
pool: sqlx::Pool<Postgres>,
|
||||||
@@ -31,6 +34,7 @@ impl BackgroundTask {
|
|||||||
search_config: search::SearchConfig,
|
search_config: search::SearchConfig,
|
||||||
clickhouse: clickhouse::Client,
|
clickhouse: clickhouse::Client,
|
||||||
stripe_client: stripe::Client,
|
stripe_client: stripe::Client,
|
||||||
|
anrok_client: anrok::Client,
|
||||||
email_queue: EmailQueue,
|
email_queue: EmailQueue,
|
||||||
) {
|
) {
|
||||||
use BackgroundTask::*;
|
use BackgroundTask::*;
|
||||||
@@ -41,8 +45,9 @@ impl BackgroundTask {
|
|||||||
UpdateVersions => update_versions(pool, redis_pool).await,
|
UpdateVersions => update_versions(pool, redis_pool).await,
|
||||||
Payouts => payouts(pool, clickhouse, redis_pool).await,
|
Payouts => payouts(pool, clickhouse, redis_pool).await,
|
||||||
IndexBilling => {
|
IndexBilling => {
|
||||||
crate::routes::internal::billing::index_billing(
|
index_billing(
|
||||||
stripe_client,
|
stripe_client,
|
||||||
|
anrok_client,
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
redis_pool,
|
redis_pool,
|
||||||
)
|
)
|
||||||
@@ -51,8 +56,11 @@ impl BackgroundTask {
|
|||||||
update_bank_balances(pool).await;
|
update_bank_balances(pool).await;
|
||||||
}
|
}
|
||||||
IndexSubscriptions => {
|
IndexSubscriptions => {
|
||||||
crate::routes::internal::billing::index_subscriptions(
|
index_subscriptions(
|
||||||
pool, redis_pool,
|
pool,
|
||||||
|
redis_pool,
|
||||||
|
stripe_client,
|
||||||
|
anrok_client,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::models::billing::{
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::convert::{TryFrom, TryInto};
|
use std::convert::{TryFrom, TryInto};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct DBCharge {
|
pub struct DBCharge {
|
||||||
pub id: DBChargeId,
|
pub id: DBChargeId,
|
||||||
pub user_id: DBUserId,
|
pub user_id: DBUserId,
|
||||||
@@ -26,8 +27,13 @@ pub struct DBCharge {
|
|||||||
|
|
||||||
pub parent_charge_id: Option<DBChargeId>,
|
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
|
// Net is always in USD
|
||||||
pub net: Option<i64>,
|
pub net: Option<i64>,
|
||||||
|
pub tax_drift_loss: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ChargeQueryResult {
|
struct ChargeQueryResult {
|
||||||
@@ -45,7 +51,11 @@ struct ChargeQueryResult {
|
|||||||
payment_platform: String,
|
payment_platform: String,
|
||||||
payment_platform_id: Option<String>,
|
payment_platform_id: Option<String>,
|
||||||
parent_charge_id: Option<i64>,
|
parent_charge_id: Option<i64>,
|
||||||
|
tax_amount: i64,
|
||||||
|
tax_platform_id: Option<String>,
|
||||||
|
tax_last_updated: Option<DateTime<Utc>>,
|
||||||
net: Option<i64>,
|
net: Option<i64>,
|
||||||
|
tax_drift_loss: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<ChargeQueryResult> for DBCharge {
|
impl TryFrom<ChargeQueryResult> for DBCharge {
|
||||||
@@ -69,7 +79,11 @@ impl TryFrom<ChargeQueryResult> for DBCharge {
|
|||||||
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
|
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
|
||||||
payment_platform_id: r.payment_platform_id,
|
payment_platform_id: r.payment_platform_id,
|
||||||
parent_charge_id: r.parent_charge_id.map(DBChargeId),
|
parent_charge_id: r.parent_charge_id.map(DBChargeId),
|
||||||
|
tax_amount: r.tax_amount,
|
||||||
|
tax_platform_id: r.tax_platform_id,
|
||||||
net: r.net,
|
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,
|
ChargeQueryResult,
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
id, user_id, price_id, amount, currency_code, status, due, last_attempt,
|
charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,
|
||||||
charge_type, subscription_id,
|
charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,
|
||||||
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
|
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
|
||||||
subscription_interval AS "subscription_interval?",
|
charges.subscription_interval AS "subscription_interval?",
|
||||||
payment_platform,
|
charges.payment_platform,
|
||||||
payment_platform_id AS "payment_platform_id?",
|
charges.payment_platform_id AS "payment_platform_id?",
|
||||||
parent_charge_id AS "parent_charge_id?",
|
charges.parent_charge_id AS "parent_charge_id?",
|
||||||
net AS "net?"
|
charges.net AS "net?",
|
||||||
|
charges.tax_last_updated AS "tax_last_updated?",
|
||||||
|
charges.tax_drift_loss AS "tax_drift_loss?"
|
||||||
FROM charges
|
FROM charges
|
||||||
"#
|
"#
|
||||||
+ $predicate,
|
+ $predicate,
|
||||||
@@ -103,8 +119,8 @@ impl DBCharge {
|
|||||||
) -> Result<DBChargeId, DatabaseError> {
|
) -> Result<DBChargeId, DatabaseError> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"
|
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)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
ON CONFLICT (id)
|
ON CONFLICT (id)
|
||||||
DO UPDATE
|
DO UPDATE
|
||||||
SET status = EXCLUDED.status,
|
SET status = EXCLUDED.status,
|
||||||
@@ -116,10 +132,14 @@ impl DBCharge {
|
|||||||
payment_platform_id = EXCLUDED.payment_platform_id,
|
payment_platform_id = EXCLUDED.payment_platform_id,
|
||||||
parent_charge_id = EXCLUDED.parent_charge_id,
|
parent_charge_id = EXCLUDED.parent_charge_id,
|
||||||
net = EXCLUDED.net,
|
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,
|
price_id = EXCLUDED.price_id,
|
||||||
amount = EXCLUDED.amount,
|
amount = EXCLUDED.amount,
|
||||||
currency_code = EXCLUDED.currency_code,
|
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.id.0,
|
||||||
self.user_id.0,
|
self.user_id.0,
|
||||||
@@ -136,6 +156,10 @@ impl DBCharge {
|
|||||||
self.payment_platform_id.as_deref(),
|
self.payment_platform_id.as_deref(),
|
||||||
self.parent_charge_id.map(|x| x.0),
|
self.parent_charge_id.map(|x| x.0),
|
||||||
self.net,
|
self.net,
|
||||||
|
self.tax_amount,
|
||||||
|
self.tax_platform_id.as_deref(),
|
||||||
|
self.tax_last_updated,
|
||||||
|
self.tax_drift_loss,
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -276,6 +300,71 @@ impl DBCharge {
|
|||||||
.collect::<Result<Vec<_>, serde_json::Error>>()?)
|
.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(
|
pub async fn remove(
|
||||||
id: DBChargeId,
|
id: DBChargeId,
|
||||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
@@ -293,3 +382,9 @@ impl DBCharge {
|
|||||||
Ok(())
|
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 payout_item;
|
||||||
pub mod payouts_values_notifications;
|
pub mod payouts_values_notifications;
|
||||||
pub mod product_item;
|
pub mod product_item;
|
||||||
|
pub mod products_tax_identifier_item;
|
||||||
pub mod project_item;
|
pub mod project_item;
|
||||||
pub mod report_item;
|
pub mod report_item;
|
||||||
pub mod session_item;
|
pub mod session_item;
|
||||||
|
|||||||
@@ -15,20 +15,22 @@ pub struct DBProduct {
|
|||||||
pub id: DBProductId,
|
pub id: DBProductId,
|
||||||
pub metadata: ProductMetadata,
|
pub metadata: ProductMetadata,
|
||||||
pub unitary: bool,
|
pub unitary: bool,
|
||||||
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProductQueryResult {
|
struct ProductQueryResult {
|
||||||
id: i64,
|
id: i64,
|
||||||
metadata: serde_json::Value,
|
metadata: serde_json::Value,
|
||||||
unitary: bool,
|
unitary: bool,
|
||||||
|
name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! select_products_with_predicate {
|
macro_rules! select_products_with_predicate {
|
||||||
($predicate:tt, $param:ident) => {
|
($predicate:tt, $param:expr) => {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
ProductQueryResult,
|
ProductQueryResult,
|
||||||
r#"
|
r#"
|
||||||
SELECT id, metadata, unitary
|
SELECT products.id, products.metadata, products.unitary, products.name
|
||||||
FROM products
|
FROM products
|
||||||
"#
|
"#
|
||||||
+ $predicate,
|
+ $predicate,
|
||||||
@@ -45,6 +47,7 @@ impl TryFrom<ProductQueryResult> for DBProduct {
|
|||||||
id: DBProductId(r.id),
|
id: DBProductId(r.id),
|
||||||
metadata: serde_json::from_value(r.metadata)?,
|
metadata: serde_json::from_value(r.metadata)?,
|
||||||
unitary: r.unitary,
|
unitary: r.unitary,
|
||||||
|
name: r.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,23 @@ impl DBProduct {
|
|||||||
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
|
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>(
|
pub async fn get_by_type<'a, E>(
|
||||||
exec: E,
|
exec: E,
|
||||||
r#type: &str,
|
r#type: &str,
|
||||||
@@ -116,6 +136,8 @@ pub struct QueryProductWithPrices {
|
|||||||
pub id: DBProductId,
|
pub id: DBProductId,
|
||||||
pub metadata: ProductMetadata,
|
pub metadata: ProductMetadata,
|
||||||
pub unitary: bool,
|
pub unitary: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
|
pub name: Option<String>,
|
||||||
pub prices: Vec<DBProductPrice>,
|
pub prices: Vec<DBProductPrice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +174,7 @@ impl QueryProductWithPrices {
|
|||||||
Some(QueryProductWithPrices {
|
Some(QueryProductWithPrices {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
metadata: x.metadata,
|
metadata: x.metadata,
|
||||||
|
name: x.name,
|
||||||
prices: prices
|
prices: prices
|
||||||
.remove(&x.id)
|
.remove(&x.id)
|
||||||
.map(|x| x.1)?
|
.map(|x| x.1)?
|
||||||
@@ -195,6 +218,7 @@ impl QueryProductWithPrices {
|
|||||||
Some(QueryProductWithPrices {
|
Some(QueryProductWithPrices {
|
||||||
id: x.id,
|
id: x.id,
|
||||||
metadata: x.metadata,
|
metadata: x.metadata,
|
||||||
|
name: x.name,
|
||||||
prices: prices
|
prices: prices
|
||||||
.remove(&x.id)
|
.remove(&x.id)
|
||||||
.map(|x| x.1)?
|
.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,
|
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
|
||||||
};
|
};
|
||||||
use crate::models::billing::{
|
use crate::models::billing::{
|
||||||
PriceDuration, SubscriptionMetadata, SubscriptionStatus,
|
PriceDuration, ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -161,3 +161,12 @@ impl DBUserSubscription {
|
|||||||
Ok(())
|
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::background_task::update_versions;
|
||||||
use crate::database::ReadOnlyPgPool;
|
use crate::database::ReadOnlyPgPool;
|
||||||
|
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||||
use crate::queue::moderation::AutomatedModerationQueue;
|
use crate::queue::moderation::AutomatedModerationQueue;
|
||||||
|
use crate::util::anrok;
|
||||||
use crate::util::env::{parse_strings_from_var, parse_var};
|
use crate::util::env::{parse_strings_from_var, parse_var};
|
||||||
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
|
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
|
||||||
use sync::friends::handle_pubsub;
|
use sync::friends::handle_pubsub;
|
||||||
@@ -58,6 +60,7 @@ pub struct LabrinthConfig {
|
|||||||
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
|
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
|
||||||
pub rate_limiter: web::Data<AsyncRateLimiter>,
|
pub rate_limiter: web::Data<AsyncRateLimiter>,
|
||||||
pub stripe_client: stripe::Client,
|
pub stripe_client: stripe::Client,
|
||||||
|
pub anrok_client: anrok::Client,
|
||||||
pub email_queue: web::Data<EmailQueue>,
|
pub email_queue: web::Data<EmailQueue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ pub fn app_setup(
|
|||||||
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
|
||||||
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
|
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
|
||||||
stripe_client: stripe::Client,
|
stripe_client: stripe::Client,
|
||||||
|
anrok_client: anrok::Client,
|
||||||
email_queue: EmailQueue,
|
email_queue: EmailQueue,
|
||||||
enable_background_tasks: bool,
|
enable_background_tasks: bool,
|
||||||
) -> LabrinthConfig {
|
) -> LabrinthConfig {
|
||||||
@@ -161,10 +165,12 @@ pub fn app_setup(
|
|||||||
let pool_ref = pool.clone();
|
let pool_ref = pool.clone();
|
||||||
let redis_ref = redis_pool.clone();
|
let redis_ref = redis_pool.clone();
|
||||||
let stripe_client_ref = stripe_client.clone();
|
let stripe_client_ref = stripe_client.clone();
|
||||||
|
let anrok_client_ref = anrok_client.clone();
|
||||||
actix_rt::spawn(async move {
|
actix_rt::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
routes::internal::billing::index_billing(
|
index_billing(
|
||||||
stripe_client_ref.clone(),
|
stripe_client_ref.clone(),
|
||||||
|
anrok_client_ref.clone(),
|
||||||
pool_ref.clone(),
|
pool_ref.clone(),
|
||||||
redis_ref.clone(),
|
redis_ref.clone(),
|
||||||
)
|
)
|
||||||
@@ -175,11 +181,16 @@ pub fn app_setup(
|
|||||||
|
|
||||||
let pool_ref = pool.clone();
|
let pool_ref = pool.clone();
|
||||||
let redis_ref = redis_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 {
|
actix_rt::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
routes::internal::billing::index_subscriptions(
|
index_subscriptions(
|
||||||
pool_ref.clone(),
|
pool_ref.clone(),
|
||||||
redis_ref.clone(),
|
redis_ref.clone(),
|
||||||
|
stripe_client_ref.clone(),
|
||||||
|
anrok_client_ref.clone(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
tokio::time::sleep(Duration::from_secs(60 * 5)).await;
|
tokio::time::sleep(Duration::from_secs(60 * 5)).await;
|
||||||
@@ -287,6 +298,7 @@ pub fn app_setup(
|
|||||||
automated_moderation_queue,
|
automated_moderation_queue,
|
||||||
rate_limiter: limiter,
|
rate_limiter: limiter,
|
||||||
stripe_client,
|
stripe_client,
|
||||||
|
anrok_client,
|
||||||
email_queue: web::Data::new(email_queue),
|
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.active_sockets.clone())
|
||||||
.app_data(labrinth_config.automated_moderation_queue.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.stripe_client.clone()))
|
||||||
|
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
|
||||||
.app_data(labrinth_config.rate_limiter.clone())
|
.app_data(labrinth_config.rate_limiter.clone())
|
||||||
.configure({
|
.configure({
|
||||||
#[cfg(target_os = "linux")]
|
#[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_API_TEAM_ID");
|
||||||
failed |= check_var::<String>("AVALARA_1099_COMPANY_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>("COMPLIANCE_PAYOUT_THRESHOLD");
|
||||||
|
|
||||||
failed |= check_var::<String>("PAYOUT_ALERT_SLACK_WEBHOOK");
|
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::file_hosting::{S3BucketConfig, S3Host};
|
||||||
use labrinth::queue::email::EmailQueue;
|
use labrinth::queue::email::EmailQueue;
|
||||||
use labrinth::search;
|
use labrinth::search;
|
||||||
|
use labrinth::util::anrok;
|
||||||
use labrinth::util::env::parse_var;
|
use labrinth::util::env::parse_var;
|
||||||
use labrinth::util::ratelimit::rate_limit_middleware;
|
use labrinth::util::ratelimit::rate_limit_middleware;
|
||||||
use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue};
|
use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue};
|
||||||
@@ -136,6 +137,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
let stripe_client =
|
let stripe_client =
|
||||||
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
||||||
|
|
||||||
|
let anrok_client = anrok::Client::from_env().unwrap();
|
||||||
let email_queue =
|
let email_queue =
|
||||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
search_config,
|
search_config,
|
||||||
clickhouse,
|
clickhouse,
|
||||||
stripe_client,
|
stripe_client,
|
||||||
|
anrok_client.clone(),
|
||||||
email_queue,
|
email_queue,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -186,6 +189,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
file_host.clone(),
|
file_host.clone(),
|
||||||
maxmind_reader.clone(),
|
maxmind_reader.clone(),
|
||||||
stripe_client,
|
stripe_client,
|
||||||
|
anrok_client.clone(),
|
||||||
email_queue,
|
email_queue,
|
||||||
!args.no_background_tasks,
|
!args.no_background_tasks,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::models::ids::{ThreadMessageId, VersionId};
|
use crate::models::ids::{ThreadMessageId, VersionId};
|
||||||
|
use crate::models::v3::billing::PriceDuration;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
ids::{
|
ids::{
|
||||||
NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId,
|
NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId,
|
||||||
|
UserSubscriptionId,
|
||||||
},
|
},
|
||||||
notifications::{Notification, NotificationAction, NotificationBody},
|
notifications::{Notification, NotificationAction, NotificationBody},
|
||||||
projects::ProjectStatus,
|
projects::ProjectStatus,
|
||||||
@@ -37,6 +39,17 @@ pub struct LegacyNotificationAction {
|
|||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum LegacyNotificationBody {
|
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 {
|
ProjectUpdate {
|
||||||
project_id: ProjectId,
|
project_id: ProjectId,
|
||||||
version_id: VersionId,
|
version_id: VersionId,
|
||||||
@@ -198,6 +211,9 @@ impl LegacyNotification {
|
|||||||
NotificationBody::PaymentFailed { .. } => {
|
NotificationBody::PaymentFailed { .. } => {
|
||||||
Some("payment_failed".to_string())
|
Some("payment_failed".to_string())
|
||||||
}
|
}
|
||||||
|
NotificationBody::TaxNotification { .. } => {
|
||||||
|
Some("tax_notification".to_string())
|
||||||
|
}
|
||||||
NotificationBody::PayoutAvailable { .. } => {
|
NotificationBody::PayoutAvailable { .. } => {
|
||||||
Some("payout_available".to_string())
|
Some("payout_available".to_string())
|
||||||
}
|
}
|
||||||
@@ -341,6 +357,27 @@ impl LegacyNotification {
|
|||||||
new_email,
|
new_email,
|
||||||
to_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 } => {
|
NotificationBody::PaymentFailed { amount, service } => {
|
||||||
LegacyNotificationBody::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)]
|
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum PriceDuration {
|
pub enum PriceDuration {
|
||||||
@@ -175,6 +184,16 @@ pub enum SubscriptionMetadata {
|
|||||||
Medal { id: String },
|
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Charge {
|
pub struct Charge {
|
||||||
pub id: ChargeId,
|
pub id: ChargeId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use super::ids::*;
|
|||||||
use crate::database::models::notification_item::DBNotification;
|
use crate::database::models::notification_item::DBNotification;
|
||||||
use crate::database::models::notification_item::DBNotificationAction;
|
use crate::database::models::notification_item::DBNotificationAction;
|
||||||
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
|
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
|
||||||
|
use crate::models::billing::PriceDuration;
|
||||||
use crate::models::ids::{
|
use crate::models::ids::{
|
||||||
NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId,
|
NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId,
|
||||||
VersionId,
|
VersionId,
|
||||||
@@ -46,6 +47,7 @@ pub enum NotificationType {
|
|||||||
PasswordRemoved,
|
PasswordRemoved,
|
||||||
EmailChanged,
|
EmailChanged,
|
||||||
PaymentFailed,
|
PaymentFailed,
|
||||||
|
TaxNotification,
|
||||||
PatCreated,
|
PatCreated,
|
||||||
ModerationMessageReceived,
|
ModerationMessageReceived,
|
||||||
ReportStatusUpdated,
|
ReportStatusUpdated,
|
||||||
@@ -76,7 +78,9 @@ impl NotificationType {
|
|||||||
NotificationType::PasswordRemoved => "password_removed",
|
NotificationType::PasswordRemoved => "password_removed",
|
||||||
NotificationType::EmailChanged => "email_changed",
|
NotificationType::EmailChanged => "email_changed",
|
||||||
NotificationType::PaymentFailed => "payment_failed",
|
NotificationType::PaymentFailed => "payment_failed",
|
||||||
|
NotificationType::TaxNotification => "tax_notification",
|
||||||
NotificationType::PatCreated => "pat_created",
|
NotificationType::PatCreated => "pat_created",
|
||||||
|
NotificationType::PayoutAvailable => "payout_available",
|
||||||
NotificationType::ModerationMessageReceived => {
|
NotificationType::ModerationMessageReceived => {
|
||||||
"moderation_message_received"
|
"moderation_message_received"
|
||||||
}
|
}
|
||||||
@@ -87,7 +91,6 @@ impl NotificationType {
|
|||||||
}
|
}
|
||||||
NotificationType::ProjectStatusNeutral => "project_status_neutral",
|
NotificationType::ProjectStatusNeutral => "project_status_neutral",
|
||||||
NotificationType::ProjectTransferred => "project_transferred",
|
NotificationType::ProjectTransferred => "project_transferred",
|
||||||
NotificationType::PayoutAvailable => "payout_available",
|
|
||||||
NotificationType::Unknown => "unknown",
|
NotificationType::Unknown => "unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,18 +113,7 @@ impl NotificationType {
|
|||||||
"password_removed" => NotificationType::PasswordRemoved,
|
"password_removed" => NotificationType::PasswordRemoved,
|
||||||
"email_changed" => NotificationType::EmailChanged,
|
"email_changed" => NotificationType::EmailChanged,
|
||||||
"payment_failed" => NotificationType::PaymentFailed,
|
"payment_failed" => NotificationType::PaymentFailed,
|
||||||
"pat_created" => NotificationType::PatCreated,
|
"tax_notification" => NotificationType::TaxNotification,
|
||||||
"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,
|
|
||||||
"unknown" => NotificationType::Unknown,
|
"unknown" => NotificationType::Unknown,
|
||||||
_ => NotificationType::Unknown,
|
_ => NotificationType::Unknown,
|
||||||
}
|
}
|
||||||
@@ -218,6 +210,17 @@ pub enum NotificationBody {
|
|||||||
amount: String,
|
amount: String,
|
||||||
service: 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 {
|
PayoutAvailable {
|
||||||
date_available: DateTime<Utc>,
|
date_available: DateTime<Utc>,
|
||||||
amount: f64,
|
amount: f64,
|
||||||
@@ -293,6 +296,9 @@ impl NotificationBody {
|
|||||||
NotificationBody::PaymentFailed { .. } => {
|
NotificationBody::PaymentFailed { .. } => {
|
||||||
NotificationType::PaymentFailed
|
NotificationType::PaymentFailed
|
||||||
}
|
}
|
||||||
|
NotificationBody::TaxNotification { .. } => {
|
||||||
|
NotificationType::TaxNotification
|
||||||
|
}
|
||||||
NotificationBody::PayoutAvailable { .. } => {
|
NotificationBody::PayoutAvailable { .. } => {
|
||||||
NotificationType::PayoutAvailable
|
NotificationType::PayoutAvailable
|
||||||
}
|
}
|
||||||
@@ -522,6 +528,12 @@ impl From<DBNotification> for Notification {
|
|||||||
"#".to_string(),
|
"#".to_string(),
|
||||||
vec![],
|
vec![],
|
||||||
),
|
),
|
||||||
|
NotificationBody::TaxNotification { .. } => (
|
||||||
|
"Tax notification".to_string(),
|
||||||
|
"You've received a tax notification.".to_string(),
|
||||||
|
"#".to_string(),
|
||||||
|
vec![],
|
||||||
|
),
|
||||||
NotificationBody::PayoutAvailable { .. } => (
|
NotificationBody::PayoutAvailable { .. } => (
|
||||||
"Payout available".to_string(),
|
"Payout available".to_string(),
|
||||||
"A payout is 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 AUTHPROVIDER_NAME: &str = "authprovider.name";
|
||||||
const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email";
|
const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email";
|
||||||
const BILLING_URL: &str = "billing.url";
|
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_AMOUNT: &str = "paymentfailed.amount";
|
||||||
const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service";
|
const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service";
|
||||||
@@ -545,12 +559,57 @@ async fn collect_template_variables(
|
|||||||
|
|
||||||
map.insert(
|
map.insert(
|
||||||
PAYOUTAVAILABLE_AMOUNT,
|
PAYOUTAVAILABLE_AMOUNT,
|
||||||
format!("{:.2}", (amount * 100.0) as i64),
|
format!("USD${:.2}", (amount * 100.0) as i64),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(map)
|
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::ProjectUpdate { .. }
|
||||||
| NotificationBody::ModeratorMessage { .. }
|
| NotificationBody::ModeratorMessage { .. }
|
||||||
| NotificationBody::LegacyMarkdown { .. }
|
| NotificationBody::LegacyMarkdown { .. }
|
||||||
@@ -561,3 +620,11 @@ async fn collect_template_variables(
|
|||||||
fn date_human_readable(date: chrono::DateTime<chrono::Utc>) -> String {
|
fn date_human_readable(date: chrono::DateTime<chrono::Utc>) -> String {
|
||||||
date.format("%B %d, %Y").to_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 analytics;
|
||||||
|
pub mod billing;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod maxmind;
|
pub mod maxmind;
|
||||||
pub mod moderation;
|
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,
|
Offer, RedeemalLookupFields, Status, UserRedeemal,
|
||||||
};
|
};
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
|
use crate::queue::billing::try_process_user_redeemal;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::routes::internal::billing::try_process_user_redeemal;
|
|
||||||
use crate::util::guards::medal_key_guard;
|
use crate::util::guards::medal_key_guard;
|
||||||
|
|
||||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ pub mod medal;
|
|||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod pats;
|
pub mod pats;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
pub mod statuses;
|
pub mod statuses;
|
||||||
|
|
||||||
pub use super::ApiError;
|
pub use super::ApiError;
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ pub enum ApiError {
|
|||||||
Conflict(String),
|
Conflict(String),
|
||||||
#[error("External tax compliance API Error")]
|
#[error("External tax compliance API Error")]
|
||||||
TaxComplianceApi,
|
TaxComplianceApi,
|
||||||
|
#[error(transparent)]
|
||||||
|
TaxProcessor(#[from] crate::util::anrok::AnrokError),
|
||||||
#[error(
|
#[error(
|
||||||
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
|
||||||
)]
|
)]
|
||||||
@@ -184,6 +186,7 @@ impl ApiError {
|
|||||||
ApiError::Io(..) => "io_error",
|
ApiError::Io(..) => "io_error",
|
||||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||||
ApiError::Stripe(..) => "stripe_error",
|
ApiError::Stripe(..) => "stripe_error",
|
||||||
|
ApiError::TaxProcessor(..) => "tax_processor_error",
|
||||||
ApiError::Slack(..) => "slack_error",
|
ApiError::Slack(..) => "slack_error",
|
||||||
},
|
},
|
||||||
description: self.to_string(),
|
description: self.to_string(),
|
||||||
@@ -223,6 +226,7 @@ impl actix_web::ResponseError for ApiError {
|
|||||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||||
|
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ApiError::Slack(..) => 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 actix;
|
||||||
|
pub mod anrok;
|
||||||
pub mod archon;
|
pub mod archon;
|
||||||
pub mod avalara1099;
|
pub mod avalara1099;
|
||||||
pub mod bitflag;
|
pub mod bitflag;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use labrinth::queue::email::EmailQueue;
|
use labrinth::queue::email::EmailQueue;
|
||||||
|
use labrinth::util::anrok;
|
||||||
use labrinth::{LabrinthConfig, file_hosting, queue};
|
use labrinth::{LabrinthConfig, file_hosting, queue};
|
||||||
use labrinth::{check_env_vars, clickhouse};
|
use labrinth::{check_env_vars, clickhouse};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -40,6 +41,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
|||||||
let stripe_client =
|
let stripe_client =
|
||||||
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
|
||||||
|
|
||||||
|
let anrok_client = anrok::Client::from_env().unwrap();
|
||||||
let email_queue =
|
let email_queue =
|
||||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
|||||||
file_host.clone(),
|
file_host.clone(),
|
||||||
maxmind_reader,
|
maxmind_reader,
|
||||||
stripe_client,
|
stripe_client,
|
||||||
|
anrok_client,
|
||||||
email_queue,
|
email_queue,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user