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:
François-Xavier Talbot
2025-09-25 12:29:29 +01:00
committed by GitHub
parent 47020f34b6
commit 4228a193e9
44 changed files with 3438 additions and 1330 deletions

View File

@@ -138,4 +138,7 @@ AVALARA_1099_COMPANY_ID=207337084
COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
ARCHON_URL=none

View File

@@ -139,4 +139,7 @@ AVALARA_1099_COMPANY_ID=207337084
COMPLIANCE_PAYOUT_THRESHOLD=disabled
ANROK_API_KEY=none
ANROK_API_URL=none
ARCHON_URL=none

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE id = ANY($1::bigint[])",
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE id = ANY($1::bigint[])",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "unitary",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
@@ -27,8 +32,9 @@
"nullable": [
false,
false,
false
false,
true
]
},
"hash": "37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b"
"hash": "042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60"
}

View 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"
}

View 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"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE id = $1",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
"hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE parent_charge_id = $1",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE parent_charge_id = $1",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"
"hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda"
}

View File

@@ -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"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9"
"hash": "6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
"hash": "7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6"
}

View 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"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE id = $1",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"
"hash": "9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca"
}

View 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"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE 1 = $1",
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE 1 = $1",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "unitary",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
@@ -27,8 +32,9 @@
"nullable": [
false,
false,
false
false,
true
]
},
"hash": "ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d"
"hash": "aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf"
}

View 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"
}

View 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"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362"
"hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
"describe": {
"columns": [
{
@@ -55,28 +55,48 @@
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
"name": "tax_amount",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "payment_platform",
"name": "tax_platform_id",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 15,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"ordinal": 16,
"name": "net?",
"type_info": "Int8"
},
{
"ordinal": 17,
"name": "tax_last_updated?",
"type_info": "Timestamptz"
},
{
"ordinal": 18,
"name": "tax_drift_loss?",
"type_info": "Int8"
}
],
"parameters": {
@@ -95,12 +115,16 @@
true,
false,
true,
false,
true,
true,
false,
true,
true,
true,
true,
true
]
},
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1",
"query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE metadata ->> 'type' = $1",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "unitary",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
@@ -27,8 +32,9 @@
"nullable": [
false,
false,
false
false,
true
]
},
"hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8"
"hash": "f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263"
}

View 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"
}

View 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';

View File

@@ -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}.'
)
);

View File

@@ -1,10 +1,12 @@
use crate::database::redis::RedisPool;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::email::EmailQueue;
use crate::queue::payouts::{
PayoutsQueue, index_payouts_notifications,
insert_bank_balances_and_webhook, process_payout,
};
use crate::search::indexing::index_projects;
use crate::util::anrok;
use crate::{database, search};
use clap::ValueEnum;
use sqlx::Postgres;
@@ -24,6 +26,7 @@ pub enum BackgroundTask {
}
impl BackgroundTask {
#[allow(clippy::too_many_arguments)]
pub async fn run(
self,
pool: sqlx::Pool<Postgres>,
@@ -31,6 +34,7 @@ impl BackgroundTask {
search_config: search::SearchConfig,
clickhouse: clickhouse::Client,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
) {
use BackgroundTask::*;
@@ -41,8 +45,9 @@ impl BackgroundTask {
UpdateVersions => update_versions(pool, redis_pool).await,
Payouts => payouts(pool, clickhouse, redis_pool).await,
IndexBilling => {
crate::routes::internal::billing::index_billing(
index_billing(
stripe_client,
anrok_client,
pool.clone(),
redis_pool,
)
@@ -51,8 +56,11 @@ impl BackgroundTask {
update_bank_balances(pool).await;
}
IndexSubscriptions => {
crate::routes::internal::billing::index_subscriptions(
pool, redis_pool,
index_subscriptions(
pool,
redis_pool,
stripe_client,
anrok_client,
)
.await
}

View File

@@ -7,6 +7,7 @@ use crate::models::billing::{
use chrono::{DateTime, Utc};
use std::convert::{TryFrom, TryInto};
#[derive(Clone)]
pub struct DBCharge {
pub id: DBChargeId,
pub user_id: DBUserId,
@@ -26,8 +27,13 @@ pub struct DBCharge {
pub parent_charge_id: Option<DBChargeId>,
pub tax_amount: i64,
pub tax_platform_id: Option<String>,
pub tax_last_updated: Option<DateTime<Utc>>,
// Net is always in USD
pub net: Option<i64>,
pub tax_drift_loss: Option<i64>,
}
struct ChargeQueryResult {
@@ -45,7 +51,11 @@ struct ChargeQueryResult {
payment_platform: String,
payment_platform_id: Option<String>,
parent_charge_id: Option<i64>,
tax_amount: i64,
tax_platform_id: Option<String>,
tax_last_updated: Option<DateTime<Utc>>,
net: Option<i64>,
tax_drift_loss: Option<i64>,
}
impl TryFrom<ChargeQueryResult> for DBCharge {
@@ -69,7 +79,11 @@ impl TryFrom<ChargeQueryResult> for DBCharge {
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
payment_platform_id: r.payment_platform_id,
parent_charge_id: r.parent_charge_id.map(DBChargeId),
tax_amount: r.tax_amount,
tax_platform_id: r.tax_platform_id,
net: r.net,
tax_last_updated: r.tax_last_updated,
tax_drift_loss: r.tax_drift_loss,
})
}
}
@@ -80,14 +94,16 @@ macro_rules! select_charges_with_predicate {
ChargeQueryResult,
r#"
SELECT
id, user_id, price_id, amount, currency_code, status, due, last_attempt,
charge_type, subscription_id,
charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,
charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
subscription_interval AS "subscription_interval?",
payment_platform,
payment_platform_id AS "payment_platform_id?",
parent_charge_id AS "parent_charge_id?",
net AS "net?"
charges.subscription_interval AS "subscription_interval?",
charges.payment_platform,
charges.payment_platform_id AS "payment_platform_id?",
charges.parent_charge_id AS "parent_charge_id?",
charges.net AS "net?",
charges.tax_last_updated AS "tax_last_updated?",
charges.tax_drift_loss AS "tax_drift_loss?"
FROM charges
"#
+ $predicate,
@@ -103,8 +119,8 @@ impl DBCharge {
) -> Result<DBChargeId, DatabaseError> {
sqlx::query!(
r#"
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
ON CONFLICT (id)
DO UPDATE
SET status = EXCLUDED.status,
@@ -116,10 +132,14 @@ impl DBCharge {
payment_platform_id = EXCLUDED.payment_platform_id,
parent_charge_id = EXCLUDED.parent_charge_id,
net = EXCLUDED.net,
tax_amount = EXCLUDED.tax_amount,
tax_platform_id = EXCLUDED.tax_platform_id,
tax_last_updated = EXCLUDED.tax_last_updated,
price_id = EXCLUDED.price_id,
amount = EXCLUDED.amount,
currency_code = EXCLUDED.currency_code,
charge_type = EXCLUDED.charge_type
charge_type = EXCLUDED.charge_type,
tax_drift_loss = EXCLUDED.tax_drift_loss
"#,
self.id.0,
self.user_id.0,
@@ -136,6 +156,10 @@ impl DBCharge {
self.payment_platform_id.as_deref(),
self.parent_charge_id.map(|x| x.0),
self.net,
self.tax_amount,
self.tax_platform_id.as_deref(),
self.tax_last_updated,
self.tax_drift_loss,
)
.execute(&mut **transaction)
.await?;
@@ -276,6 +300,71 @@ impl DBCharge {
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
/// Returns all charges that need to have their tax amount updated.
///
/// This only selects charges which are:
/// - Open;
/// - Haven't been updated in the last day;
/// - Are due in more than 7 days;
/// - Where the user has an email, because we can't notify users without an email about a price change.
///
/// This also locks the charges.
pub async fn get_updateable_lock(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
limit: i64,
) -> Result<Vec<DBCharge>, DatabaseError> {
let res = select_charges_with_predicate!(
"
INNER JOIN users u ON u.id = charges.user_id
WHERE
status = 'open'
AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'
AND u.email IS NOT NULL
AND due - INTERVAL '7 days' > NOW()
ORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC
FOR NO KEY UPDATE SKIP LOCKED
LIMIT $1
",
limit
)
.fetch_all(exec)
.await?;
Ok(res
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
/// Returns all charges which are missing a tax identifier, that is, are 1. succeeded, 2. have a tax amount and
/// 3. haven't been assigned a tax identifier yet.
///
/// Charges are locked.
pub async fn get_missing_tax_identifier_lock(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
limit: i64,
) -> Result<Vec<DBCharge>, DatabaseError> {
let res = select_charges_with_predicate!(
"
WHERE
status = 'succeeded'
AND tax_platform_id IS NULL
AND tax_amount <> 0
ORDER BY due ASC
FOR NO KEY UPDATE SKIP LOCKED
LIMIT $1
",
limit
)
.fetch_all(exec)
.await?;
Ok(res
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn remove(
id: DBChargeId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@@ -293,3 +382,9 @@ impl DBCharge {
Ok(())
}
}
pub struct CustomerCharge {
pub stripe_customer_id: String,
pub charge: DBCharge,
pub product_tax_id: String,
}

View File

@@ -22,6 +22,7 @@ pub mod pat_item;
pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod products_tax_identifier_item;
pub mod project_item;
pub mod report_item;
pub mod session_item;

View File

@@ -15,20 +15,22 @@ pub struct DBProduct {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
pub name: Option<String>,
}
struct ProductQueryResult {
id: i64,
metadata: serde_json::Value,
unitary: bool,
name: Option<String>,
}
macro_rules! select_products_with_predicate {
($predicate:tt, $param:ident) => {
($predicate:tt, $param:expr) => {
sqlx::query_as!(
ProductQueryResult,
r#"
SELECT id, metadata, unitary
SELECT products.id, products.metadata, products.unitary, products.name
FROM products
"#
+ $predicate,
@@ -45,6 +47,7 @@ impl TryFrom<ProductQueryResult> for DBProduct {
id: DBProductId(r.id),
metadata: serde_json::from_value(r.metadata)?,
unitary: r.unitary,
name: r.name,
})
}
}
@@ -57,6 +60,23 @@ impl DBProduct {
Ok(Self::get_many(&[id], exec).await?.into_iter().next())
}
pub async fn get_price(
id: DBProductPriceId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<DBProduct>, DatabaseError> {
let maybe_row = select_products_with_predicate!(
"INNER JOIN products_prices pp ON pp.id = $1
WHERE products.id = pp.product_id",
id.0
)
.fetch_optional(exec)
.await?;
maybe_row
.map(|r| r.try_into().map_err(Into::into))
.transpose()
}
pub async fn get_by_type<'a, E>(
exec: E,
r#type: &str,
@@ -116,6 +136,8 @@ pub struct QueryProductWithPrices {
pub id: DBProductId,
pub metadata: ProductMetadata,
pub unitary: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
pub prices: Vec<DBProductPrice>,
}
@@ -152,6 +174,7 @@ impl QueryProductWithPrices {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?
@@ -195,6 +218,7 @@ impl QueryProductWithPrices {
Some(QueryProductWithPrices {
id: x.id,
metadata: x.metadata,
name: x.name,
prices: prices
.remove(&x.id)
.map(|x| x.1)?

View File

@@ -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)?,
})),
}
}

View File

@@ -2,7 +2,7 @@ use crate::database::models::{
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
};
use crate::models::billing::{
PriceDuration, SubscriptionMetadata, SubscriptionStatus,
PriceDuration, ProductMetadata, SubscriptionMetadata, SubscriptionStatus,
};
use chrono::{DateTime, Utc};
use itertools::Itertools;
@@ -161,3 +161,12 @@ impl DBUserSubscription {
Ok(())
}
}
pub struct SubscriptionWithCharge {
pub subscription_id: DBUserSubscriptionId,
pub user_id: DBUserId,
pub product_metadata: ProductMetadata,
pub amount: i64,
pub tax_amount: i64,
pub due: DateTime<Utc>,
}

View File

@@ -16,7 +16,9 @@ use util::cors::default_cors;
use crate::background_task::update_versions;
use crate::database::ReadOnlyPgPool;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::util::anrok;
use crate::util::env::{parse_strings_from_var, parse_var};
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
use sync::friends::handle_pubsub;
@@ -58,6 +60,7 @@ pub struct LabrinthConfig {
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
pub rate_limiter: web::Data<AsyncRateLimiter>,
pub stripe_client: stripe::Client,
pub anrok_client: anrok::Client,
pub email_queue: web::Data<EmailQueue>,
}
@@ -71,6 +74,7 @@ pub fn app_setup(
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
maxmind: Arc<queue::maxmind::MaxMindIndexer>,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
enable_background_tasks: bool,
) -> LabrinthConfig {
@@ -161,10 +165,12 @@ pub fn app_setup(
let pool_ref = pool.clone();
let redis_ref = redis_pool.clone();
let stripe_client_ref = stripe_client.clone();
let anrok_client_ref = anrok_client.clone();
actix_rt::spawn(async move {
loop {
routes::internal::billing::index_billing(
index_billing(
stripe_client_ref.clone(),
anrok_client_ref.clone(),
pool_ref.clone(),
redis_ref.clone(),
)
@@ -175,11 +181,16 @@ pub fn app_setup(
let pool_ref = pool.clone();
let redis_ref = redis_pool.clone();
let stripe_client_ref = stripe_client.clone();
let anrok_client_ref = anrok_client.clone();
actix_rt::spawn(async move {
loop {
routes::internal::billing::index_subscriptions(
index_subscriptions(
pool_ref.clone(),
redis_ref.clone(),
stripe_client_ref.clone(),
anrok_client_ref.clone(),
)
.await;
tokio::time::sleep(Duration::from_secs(60 * 5)).await;
@@ -287,6 +298,7 @@ pub fn app_setup(
automated_moderation_queue,
rate_limiter: limiter,
stripe_client,
anrok_client,
email_queue: web::Data::new(email_queue),
}
}
@@ -322,6 +334,7 @@ pub fn app_config(
.app_data(labrinth_config.active_sockets.clone())
.app_data(labrinth_config.automated_moderation_queue.clone())
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
.app_data(labrinth_config.rate_limiter.clone())
.configure({
#[cfg(target_os = "linux")]
@@ -501,6 +514,9 @@ pub fn check_env_vars() -> bool {
failed |= check_var::<String>("AVALARA_1099_API_TEAM_ID");
failed |= check_var::<String>("AVALARA_1099_COMPANY_ID");
failed |= check_var::<String>("ANROK_API_URL");
failed |= check_var::<String>("ANROK_API_KEY");
failed |= check_var::<String>("COMPLIANCE_PAYOUT_THRESHOLD");
failed |= check_var::<String>("PAYOUT_ALERT_SLACK_WEBHOOK");

View File

@@ -8,6 +8,7 @@ use labrinth::database::redis::RedisPool;
use labrinth::file_hosting::{S3BucketConfig, S3Host};
use labrinth::queue::email::EmailQueue;
use labrinth::search;
use labrinth::util::anrok;
use labrinth::util::env::parse_var;
use labrinth::util::ratelimit::rate_limit_middleware;
use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue};
@@ -136,6 +137,7 @@ async fn main() -> std::io::Result<()> {
let stripe_client =
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
@@ -147,6 +149,7 @@ async fn main() -> std::io::Result<()> {
search_config,
clickhouse,
stripe_client,
anrok_client.clone(),
email_queue,
)
.await;
@@ -186,6 +189,7 @@ async fn main() -> std::io::Result<()> {
file_host.clone(),
maxmind_reader.clone(),
stripe_client,
anrok_client.clone(),
email_queue,
!args.no_background_tasks,
);

View File

@@ -1,7 +1,9 @@
use crate::models::ids::{ThreadMessageId, VersionId};
use crate::models::v3::billing::PriceDuration;
use crate::models::{
ids::{
NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId,
UserSubscriptionId,
},
notifications::{Notification, NotificationAction, NotificationBody},
projects::ProjectStatus,
@@ -37,6 +39,17 @@ pub struct LegacyNotificationAction {
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LegacyNotificationBody {
TaxNotification {
subscription_id: UserSubscriptionId,
old_amount: i64,
old_tax_amount: i64,
new_amount: i64,
new_tax_amount: i64,
billing_interval: PriceDuration,
currency: String,
due: DateTime<Utc>,
service: String,
},
ProjectUpdate {
project_id: ProjectId,
version_id: VersionId,
@@ -198,6 +211,9 @@ impl LegacyNotification {
NotificationBody::PaymentFailed { .. } => {
Some("payment_failed".to_string())
}
NotificationBody::TaxNotification { .. } => {
Some("tax_notification".to_string())
}
NotificationBody::PayoutAvailable { .. } => {
Some("payout_available".to_string())
}
@@ -341,6 +357,27 @@ impl LegacyNotification {
new_email,
to_email,
},
NotificationBody::TaxNotification {
subscription_id,
old_amount,
old_tax_amount,
new_amount,
new_tax_amount,
billing_interval,
currency,
due,
service,
} => LegacyNotificationBody::TaxNotification {
subscription_id,
old_amount,
old_tax_amount,
new_amount,
new_tax_amount,
billing_interval,
due,
service,
currency,
},
NotificationBody::PaymentFailed { amount, service } => {
LegacyNotificationBody::PaymentFailed { amount, service }
}

View File

@@ -66,6 +66,15 @@ pub enum Price {
},
}
impl Price {
pub fn get_interval(&self, interval: PriceDuration) -> Option<i32> {
match self {
Price::OneTime { .. } => None,
Price::Recurring { intervals } => intervals.get(&interval).copied(),
}
}
}
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
#[serde(rename_all = "kebab-case")]
pub enum PriceDuration {
@@ -175,6 +184,16 @@ pub enum SubscriptionMetadata {
Medal { id: String },
}
impl SubscriptionMetadata {
pub fn is_medal(&self) -> bool {
matches!(self, SubscriptionMetadata::Medal { .. })
}
pub fn is_pyro(&self) -> bool {
matches!(self, SubscriptionMetadata::Pyro { .. })
}
}
#[derive(Serialize, Deserialize)]
pub struct Charge {
pub id: ChargeId,

View File

@@ -2,6 +2,7 @@ use super::ids::*;
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::DBNotificationAction;
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
use crate::models::billing::PriceDuration;
use crate::models::ids::{
NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId,
VersionId,
@@ -46,6 +47,7 @@ pub enum NotificationType {
PasswordRemoved,
EmailChanged,
PaymentFailed,
TaxNotification,
PatCreated,
ModerationMessageReceived,
ReportStatusUpdated,
@@ -76,7 +78,9 @@ impl NotificationType {
NotificationType::PasswordRemoved => "password_removed",
NotificationType::EmailChanged => "email_changed",
NotificationType::PaymentFailed => "payment_failed",
NotificationType::TaxNotification => "tax_notification",
NotificationType::PatCreated => "pat_created",
NotificationType::PayoutAvailable => "payout_available",
NotificationType::ModerationMessageReceived => {
"moderation_message_received"
}
@@ -87,7 +91,6 @@ impl NotificationType {
}
NotificationType::ProjectStatusNeutral => "project_status_neutral",
NotificationType::ProjectTransferred => "project_transferred",
NotificationType::PayoutAvailable => "payout_available",
NotificationType::Unknown => "unknown",
}
}
@@ -110,18 +113,7 @@ impl NotificationType {
"password_removed" => NotificationType::PasswordRemoved,
"email_changed" => NotificationType::EmailChanged,
"payment_failed" => NotificationType::PaymentFailed,
"pat_created" => NotificationType::PatCreated,
"moderation_message_received" => {
NotificationType::ModerationMessageReceived
}
"report_status_updated" => NotificationType::ReportStatusUpdated,
"report_submitted" => NotificationType::ReportSubmitted,
"project_status_approved" => {
NotificationType::ProjectStatusApproved
}
"project_status_neutral" => NotificationType::ProjectStatusNeutral,
"project_transferred" => NotificationType::ProjectTransferred,
"payout_available" => NotificationType::PayoutAvailable,
"tax_notification" => NotificationType::TaxNotification,
"unknown" => NotificationType::Unknown,
_ => NotificationType::Unknown,
}
@@ -218,6 +210,17 @@ pub enum NotificationBody {
amount: String,
service: String,
},
TaxNotification {
subscription_id: UserSubscriptionId,
new_amount: i64,
new_tax_amount: i64,
old_amount: i64,
old_tax_amount: i64,
billing_interval: PriceDuration,
currency: String,
due: DateTime<Utc>,
service: String,
},
PayoutAvailable {
date_available: DateTime<Utc>,
amount: f64,
@@ -293,6 +296,9 @@ impl NotificationBody {
NotificationBody::PaymentFailed { .. } => {
NotificationType::PaymentFailed
}
NotificationBody::TaxNotification { .. } => {
NotificationType::TaxNotification
}
NotificationBody::PayoutAvailable { .. } => {
NotificationType::PayoutAvailable
}
@@ -522,6 +528,12 @@ impl From<DBNotification> for Notification {
"#".to_string(),
vec![],
),
NotificationBody::TaxNotification { .. } => (
"Tax notification".to_string(),
"You've received a tax notification.".to_string(),
"#".to_string(),
vec![],
),
NotificationBody::PayoutAvailable { .. } => (
"Payout available".to_string(),
"A payout is available!".to_string(),

View 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");
}

View File

@@ -24,6 +24,20 @@ const VERIFYEMAIL_URL: &str = "verifyemail.url";
const AUTHPROVIDER_NAME: &str = "authprovider.name";
const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email";
const BILLING_URL: &str = "billing.url";
const SUBSCRIPTION_ID: &str = "subscription.id";
const TAXNOTIFICATION_OLD_AMOUNT: &str = "taxnotification.old_amount";
const TAXNOTIFICATION_OLD_TAX_AMOUNT: &str = "taxnotification.old_tax_amount";
const TAXNOTIFICATION_OLD_TOTAL_AMOUNT: &str =
"taxnotification.old_total_amount";
const TAXNOTIFICATION_NEW_AMOUNT: &str = "taxnotification.new_amount";
const TAXNOTIFICATION_NEW_TAX_AMOUNT: &str = "taxnotification.new_tax_amount";
const TAXNOTIFICATION_NEW_TOTAL_AMOUNT: &str =
"taxnotification.new_total_amount";
const TAXNOTIFICATION_BILLING_INTERVAL: &str =
"taxnotification.billing_interval";
const TAXNOTIFICATION_DUE: &str = "taxnotification.due";
const TAXNOTIFICATION_SERVICE: &str = "taxnotification.service";
const PAYMENTFAILED_AMOUNT: &str = "paymentfailed.amount";
const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service";
@@ -545,12 +559,57 @@ async fn collect_template_variables(
map.insert(
PAYOUTAVAILABLE_AMOUNT,
format!("{:.2}", (amount * 100.0) as i64),
format!("USD${:.2}", (amount * 100.0) as i64),
);
Ok(map)
}
NotificationBody::TaxNotification {
subscription_id,
old_amount,
old_tax_amount,
new_amount,
new_tax_amount,
billing_interval,
currency,
due,
service,
} => {
map.insert(
TAXNOTIFICATION_OLD_AMOUNT,
fmt_money(*old_amount, currency),
);
map.insert(
TAXNOTIFICATION_OLD_TAX_AMOUNT,
fmt_money(*old_tax_amount, currency),
);
map.insert(
TAXNOTIFICATION_OLD_TOTAL_AMOUNT,
fmt_money(*old_amount + *old_tax_amount, currency),
);
map.insert(
TAXNOTIFICATION_NEW_AMOUNT,
fmt_money(*new_amount, currency),
);
map.insert(
TAXNOTIFICATION_NEW_TAX_AMOUNT,
fmt_money(*new_tax_amount, currency),
);
map.insert(
TAXNOTIFICATION_NEW_TOTAL_AMOUNT,
fmt_money(*new_amount + *new_tax_amount, currency),
);
map.insert(
TAXNOTIFICATION_BILLING_INTERVAL,
billing_interval.as_str().to_owned(),
);
map.insert(TAXNOTIFICATION_DUE, date_human_readable(*due));
map.insert(TAXNOTIFICATION_SERVICE, service.clone());
map.insert(SUBSCRIPTION_ID, to_base62(subscription_id.0));
Ok(map)
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
@@ -561,3 +620,11 @@ async fn collect_template_variables(
fn date_human_readable(date: chrono::DateTime<chrono::Utc>) -> String {
date.format("%B %d, %Y").to_string()
}
fn fmt_money(amount: i64, currency: &str) -> String {
rusty_money::Money::from_minor(
amount,
rusty_money::iso::find(currency).unwrap_or(rusty_money::iso::USD),
)
.to_string()
}

View File

@@ -1,4 +1,5 @@
pub mod analytics;
pub mod billing;
pub mod email;
pub mod maxmind;
pub mod moderation;

File diff suppressed because it is too large Load Diff

View 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
},
})
}

View File

@@ -9,8 +9,8 @@ use crate::database::models::users_redeemals::{
Offer, RedeemalLookupFields, Status, UserRedeemal,
};
use crate::database::redis::RedisPool;
use crate::queue::billing::try_process_user_redeemal;
use crate::routes::ApiError;
use crate::routes::internal::billing::try_process_user_redeemal;
use crate::util::guards::medal_key_guard;
pub fn config(cfg: &mut web::ServiceConfig) {

View File

@@ -8,7 +8,6 @@ pub mod medal;
pub mod moderation;
pub mod pats;
pub mod session;
pub mod statuses;
pub use super::ApiError;

View File

@@ -143,6 +143,8 @@ pub enum ApiError {
Conflict(String),
#[error("External tax compliance API Error")]
TaxComplianceApi,
#[error(transparent)]
TaxProcessor(#[from] crate::util::anrok::AnrokError),
#[error(
"You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
)]
@@ -184,6 +186,7 @@ impl ApiError {
ApiError::Io(..) => "io_error",
ApiError::RateLimitError(..) => "ratelimit_error",
ApiError::Stripe(..) => "stripe_error",
ApiError::TaxProcessor(..) => "tax_processor_error",
ApiError::Slack(..) => "slack_error",
},
description: self.to_string(),
@@ -223,6 +226,7 @@ impl actix_web::ResponseError for ApiError {
ApiError::Io(..) => StatusCode::BAD_REQUEST,
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}

View 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)
}
}

View File

@@ -1,4 +1,5 @@
pub mod actix;
pub mod anrok;
pub mod archon;
pub mod avalara1099;
pub mod bitflag;

View File

@@ -1,4 +1,5 @@
use labrinth::queue::email::EmailQueue;
use labrinth::util::anrok;
use labrinth::{LabrinthConfig, file_hosting, queue};
use labrinth::{check_env_vars, clickhouse};
use std::sync::Arc;
@@ -40,6 +41,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
let stripe_client =
stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap());
let anrok_client = anrok::Client::from_env().unwrap();
let email_queue =
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
@@ -52,6 +54,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
file_host.clone(),
maxmind_reader,
stripe_client,
anrok_client,
email_queue,
false,
)