max / makenotwork
22 files changed,
+1257 insertions,
-167 deletions
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS \"total!\" FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "total!", | |
| 9 | + | "type_info": "Int8" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "1b173822c0dfe66ea3bb9ae1aea044cb353aaeab38da1e015629fe1b571d19bd" | |
| 22 | + | } |
| @@ -0,0 +1,70 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT id AS \"id: LicenseKeyId\", item_id AS \"item_id: ItemId\",\n owner_id AS \"owner_id: UserId\", transaction_id AS \"transaction_id: TransactionId\",\n key_code AS \"key_code: KeyCode\", max_activations, activation_count,\n revoked_at AS \"revoked_at: chrono::DateTime<chrono::Utc>\",\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n FROM license_keys WHERE transaction_id = $1\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: LicenseKeyId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "item_id: ItemId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "owner_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "transaction_id: TransactionId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "key_code: KeyCode", | |
| 29 | + | "type_info": "Varchar" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "max_activations", | |
| 34 | + | "type_info": "Int4" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "activation_count", | |
| 39 | + | "type_info": "Int4" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "revoked_at: chrono::DateTime<chrono::Utc>", | |
| 44 | + | "type_info": "Timestamptz" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 49 | + | "type_info": "Timestamptz" | |
| 50 | + | } | |
| 51 | + | ], | |
| 52 | + | "parameters": { | |
| 53 | + | "Left": [ | |
| 54 | + | "Uuid" | |
| 55 | + | ] | |
| 56 | + | }, | |
| 57 | + | "nullable": [ | |
| 58 | + | false, | |
| 59 | + | false, | |
| 60 | + | false, | |
| 61 | + | true, | |
| 62 | + | false, | |
| 63 | + | true, | |
| 64 | + | false, | |
| 65 | + | true, | |
| 66 | + | false | |
| 67 | + | ] | |
| 68 | + | }, | |
| 69 | + | "hash": "365d41311e8f51f763af4850927b74bfdab786ec86ebe575dc560c4cc22600ae" | |
| 70 | + | } |
| @@ -0,0 +1,90 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n id AS \"id: TipId\", tipper_id AS \"tipper_id: UserId\", recipient_id AS \"recipient_id: UserId\",\n project_id AS \"project_id: ProjectId\", amount_cents AS \"amount_cents: Cents\", message,\n status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n stripe_transfer_group,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\"\n FROM tips\n WHERE tipper_id = $1 AND status = 'completed'\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TipId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tipper_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "recipient_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "project_id: ProjectId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "message", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "status: super::TransactionStatus", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "stripe_payment_intent_id", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_checkout_session_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_transfer_group", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | } | |
| 66 | + | ], | |
| 67 | + | "parameters": { | |
| 68 | + | "Left": [ | |
| 69 | + | "Uuid", | |
| 70 | + | "Int8", | |
| 71 | + | "Int8" | |
| 72 | + | ] | |
| 73 | + | }, | |
| 74 | + | "nullable": [ | |
| 75 | + | false, | |
| 76 | + | false, | |
| 77 | + | false, | |
| 78 | + | true, | |
| 79 | + | false, | |
| 80 | + | true, | |
| 81 | + | false, | |
| 82 | + | true, | |
| 83 | + | true, | |
| 84 | + | true, | |
| 85 | + | false, | |
| 86 | + | true | |
| 87 | + | ] | |
| 88 | + | }, | |
| 89 | + | "hash": "36e369ea83eb4c8337f4a4c3ccde3c5ceabb68b38d458060ec45352f67a1c86e" | |
| 90 | + | } |
| @@ -0,0 +1,89 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n UPDATE tips\n SET status = 'completed',\n stripe_payment_intent_id = $2,\n completed_at = NOW()\n WHERE stripe_checkout_session_id = $1\n AND status = 'pending'\n RETURNING\n id AS \"id: TipId\", tipper_id AS \"tipper_id: UserId\", recipient_id AS \"recipient_id: UserId\",\n project_id AS \"project_id: ProjectId\", amount_cents AS \"amount_cents: Cents\", message,\n status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n stripe_transfer_group,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\"\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TipId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tipper_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "recipient_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "project_id: ProjectId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "message", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "status: super::TransactionStatus", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "stripe_payment_intent_id", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_checkout_session_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_transfer_group", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | } | |
| 66 | + | ], | |
| 67 | + | "parameters": { | |
| 68 | + | "Left": [ | |
| 69 | + | "Text", | |
| 70 | + | "Varchar" | |
| 71 | + | ] | |
| 72 | + | }, | |
| 73 | + | "nullable": [ | |
| 74 | + | false, | |
| 75 | + | false, | |
| 76 | + | false, | |
| 77 | + | true, | |
| 78 | + | false, | |
| 79 | + | true, | |
| 80 | + | false, | |
| 81 | + | true, | |
| 82 | + | true, | |
| 83 | + | true, | |
| 84 | + | false, | |
| 85 | + | true | |
| 86 | + | ] | |
| 87 | + | }, | |
| 88 | + | "hash": "6b1d0dbffd82a8902fa1e2cad2ae7c26135dca1ab462ee72d09bda12b4b3bb7d" | |
| 89 | + | } |
| @@ -0,0 +1,22 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "SELECT COUNT(*) AS \"count!\" FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "count!", | |
| 9 | + | "type_info": "Int8" | |
| 10 | + | } | |
| 11 | + | ], | |
| 12 | + | "parameters": { | |
| 13 | + | "Left": [ | |
| 14 | + | "Uuid" | |
| 15 | + | ] | |
| 16 | + | }, | |
| 17 | + | "nullable": [ | |
| 18 | + | null | |
| 19 | + | ] | |
| 20 | + | }, | |
| 21 | + | "hash": "8f93450ef8d93e5aa9955348623030d3779b9e1903a16a94b8107646ef0f3ef3" | |
| 22 | + | } |
| @@ -0,0 +1,148 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT\n id AS \"id: TransactionId\", buyer_id AS \"buyer_id: UserId\", seller_id AS \"seller_id: UserId\",\n item_id AS \"item_id: ItemId\", amount_cents AS \"amount_cents: Cents\", platform_fee_cents AS \"platform_fee_cents: Cents\",\n currency, status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\", completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\",\n item_title, seller_username, share_contact, project_id AS \"project_id: ProjectId\",\n parent_transaction_id AS \"parent_transaction_id: TransactionId\", promo_code_id AS \"promo_code_id: PromoCodeId\",\n guest_email, claim_token AS \"claim_token: ClaimToken\", claimed_by AS \"claimed_by: UserId\",\n download_token AS \"download_token: DownloadToken\"\n FROM transactions\n WHERE stripe_checkout_session_id = $1 AND status = 'completed'\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TransactionId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "buyer_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "seller_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "item_id: ItemId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "platform_fee_cents: Cents", | |
| 34 | + | "type_info": "Int4" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "currency", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "status: super::TransactionStatus", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_payment_intent_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_checkout_session_id", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | "ordinal": 12, | |
| 68 | + | "name": "item_title", | |
| 69 | + | "type_info": "Varchar" | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | "ordinal": 13, | |
| 73 | + | "name": "seller_username", | |
| 74 | + | "type_info": "Varchar" | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | "ordinal": 14, | |
| 78 | + | "name": "share_contact", | |
| 79 | + | "type_info": "Bool" | |
| 80 | + | }, | |
| 81 | + | { | |
| 82 | + | "ordinal": 15, | |
| 83 | + | "name": "project_id: ProjectId", | |
| 84 | + | "type_info": "Uuid" | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | "ordinal": 16, | |
| 88 | + | "name": "parent_transaction_id: TransactionId", | |
| 89 | + | "type_info": "Uuid" | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | "ordinal": 17, | |
| 93 | + | "name": "promo_code_id: PromoCodeId", | |
| 94 | + | "type_info": "Uuid" | |
| 95 | + | }, | |
| 96 | + | { | |
| 97 | + | "ordinal": 18, | |
| 98 | + | "name": "guest_email", | |
| 99 | + | "type_info": "Varchar" | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | "ordinal": 19, | |
| 103 | + | "name": "claim_token: ClaimToken", | |
| 104 | + | "type_info": "Uuid" | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | "ordinal": 20, | |
| 108 | + | "name": "claimed_by: UserId", | |
| 109 | + | "type_info": "Uuid" | |
| 110 | + | }, | |
| 111 | + | { | |
| 112 | + | "ordinal": 21, | |
| 113 | + | "name": "download_token: DownloadToken", | |
| 114 | + | "type_info": "Uuid" | |
| 115 | + | } | |
| 116 | + | ], | |
| 117 | + | "parameters": { | |
| 118 | + | "Left": [ | |
| 119 | + | "Text" | |
| 120 | + | ] | |
| 121 | + | }, | |
| 122 | + | "nullable": [ | |
| 123 | + | false, | |
| 124 | + | true, | |
| 125 | + | true, | |
| 126 | + | true, | |
| 127 | + | false, | |
| 128 | + | false, | |
| 129 | + | false, | |
| 130 | + | false, | |
| 131 | + | true, | |
| 132 | + | true, | |
| 133 | + | false, | |
| 134 | + | true, | |
| 135 | + | true, | |
| 136 | + | true, | |
| 137 | + | false, | |
| 138 | + | true, | |
| 139 | + | true, | |
| 140 | + | true, | |
| 141 | + | true, | |
| 142 | + | true, | |
| 143 | + | true, | |
| 144 | + | true | |
| 145 | + | ] | |
| 146 | + | }, | |
| 147 | + | "hash": "c442eef4152cb7aa4fcf4298f5ce170211c902f831a787eef055242d85dcbfe6" | |
| 148 | + | } |
| @@ -0,0 +1,84 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n SELECT t.id AS \"id: TipId\", t.tipper_id AS \"tipper_id: UserId\", t.recipient_id AS \"recipient_id: UserId\",\n t.project_id AS \"project_id: ProjectId\", t.amount_cents AS \"amount_cents: Cents\",\n t.message, t.status AS \"status: super::TransactionStatus\",\n t.created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n t.completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\",\n u.username AS tipper_username, u.display_name AS tipper_display_name\n FROM tips t\n JOIN users u ON u.id = t.tipper_id\n WHERE t.recipient_id = $1 AND t.status = 'completed'\n ORDER BY t.created_at DESC\n LIMIT $2 OFFSET $3\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TipId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tipper_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "recipient_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "project_id: ProjectId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "message", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "status: super::TransactionStatus", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 44 | + | "type_info": "Timestamptz" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 49 | + | "type_info": "Timestamptz" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "tipper_username", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "tipper_display_name", | |
| 59 | + | "type_info": "Varchar" | |
| 60 | + | } | |
| 61 | + | ], | |
| 62 | + | "parameters": { | |
| 63 | + | "Left": [ | |
| 64 | + | "Uuid", | |
| 65 | + | "Int8", | |
| 66 | + | "Int8" | |
| 67 | + | ] | |
| 68 | + | }, | |
| 69 | + | "nullable": [ | |
| 70 | + | false, | |
| 71 | + | false, | |
| 72 | + | false, | |
| 73 | + | true, | |
| 74 | + | false, | |
| 75 | + | true, | |
| 76 | + | false, | |
| 77 | + | false, | |
| 78 | + | true, | |
| 79 | + | false, | |
| 80 | + | true | |
| 81 | + | ] | |
| 82 | + | }, | |
| 83 | + | "hash": "c71cc7d733de0e4bebfd201d882a1a98213c4178481d0ef90baeb75314b66adf" | |
| 84 | + | } |
| @@ -0,0 +1,93 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id AS \"id: TipId\", tipper_id AS \"tipper_id: UserId\", recipient_id AS \"recipient_id: UserId\",\n project_id AS \"project_id: ProjectId\", amount_cents AS \"amount_cents: Cents\", message,\n status AS \"status: super::TransactionStatus\", stripe_payment_intent_id, stripe_checkout_session_id,\n stripe_transfer_group,\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\",\n completed_at AS \"completed_at: chrono::DateTime<chrono::Utc>\"\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [ | |
| 6 | + | { | |
| 7 | + | "ordinal": 0, | |
| 8 | + | "name": "id: TipId", | |
| 9 | + | "type_info": "Uuid" | |
| 10 | + | }, | |
| 11 | + | { | |
| 12 | + | "ordinal": 1, | |
| 13 | + | "name": "tipper_id: UserId", | |
| 14 | + | "type_info": "Uuid" | |
| 15 | + | }, | |
| 16 | + | { | |
| 17 | + | "ordinal": 2, | |
| 18 | + | "name": "recipient_id: UserId", | |
| 19 | + | "type_info": "Uuid" | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | "ordinal": 3, | |
| 23 | + | "name": "project_id: ProjectId", | |
| 24 | + | "type_info": "Uuid" | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | "ordinal": 4, | |
| 28 | + | "name": "amount_cents: Cents", | |
| 29 | + | "type_info": "Int4" | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | "ordinal": 5, | |
| 33 | + | "name": "message", | |
| 34 | + | "type_info": "Varchar" | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | "ordinal": 6, | |
| 38 | + | "name": "status: super::TransactionStatus", | |
| 39 | + | "type_info": "Varchar" | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | "ordinal": 7, | |
| 43 | + | "name": "stripe_payment_intent_id", | |
| 44 | + | "type_info": "Varchar" | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | "ordinal": 8, | |
| 48 | + | "name": "stripe_checkout_session_id", | |
| 49 | + | "type_info": "Varchar" | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | "ordinal": 9, | |
| 53 | + | "name": "stripe_transfer_group", | |
| 54 | + | "type_info": "Varchar" | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | "ordinal": 10, | |
| 58 | + | "name": "created_at: chrono::DateTime<chrono::Utc>", | |
| 59 | + | "type_info": "Timestamptz" | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | "ordinal": 11, | |
| 63 | + | "name": "completed_at: chrono::DateTime<chrono::Utc>", | |
| 64 | + | "type_info": "Timestamptz" | |
| 65 | + | } | |
| 66 | + | ], | |
| 67 | + | "parameters": { | |
| 68 | + | "Left": [ | |
| 69 | + | "Uuid", | |
| 70 | + | "Uuid", | |
| 71 | + | "Uuid", | |
| 72 | + | "Int4", | |
| 73 | + | "Varchar", | |
| 74 | + | "Varchar" | |
| 75 | + | ] | |
| 76 | + | }, | |
| 77 | + | "nullable": [ | |
| 78 | + | false, | |
| 79 | + | false, | |
| 80 | + | false, | |
| 81 | + | true, | |
| 82 | + | false, | |
| 83 | + | true, | |
| 84 | + | false, | |
| 85 | + | true, | |
| 86 | + | true, | |
| 87 | + | true, | |
| 88 | + | false, | |
| 89 | + | true | |
| 90 | + | ] | |
| 91 | + | }, | |
| 92 | + | "hash": "e8aff7effadd1600fcf9496564f52028340cc9187cae2778984fe90e9e3eda77" | |
| 93 | + | } |
| @@ -0,0 +1,14 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n UPDATE tips\n SET status = 'refunded'\n WHERE stripe_payment_intent_id = $1 AND status = 'completed'\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [], | |
| 6 | + | "parameters": { | |
| 7 | + | "Left": [ | |
| 8 | + | "Text" | |
| 9 | + | ] | |
| 10 | + | }, | |
| 11 | + | "nullable": [] | |
| 12 | + | }, | |
| 13 | + | "hash": "ea0c7de9b7e1a7eaa6be491ba0acac1fe935f83bcaaa925f6e133c2b5f033ea9" | |
| 14 | + | } |
| @@ -0,0 +1,24 @@ | |||
| 1 | + | -- Payment finalize idempotency (ultra-fuzz Payments A+, finding M-Pay1). | |
| 2 | + | -- | |
| 3 | + | -- Webhook secondary effects (license-key mint, revenue splits, etc.) run after | |
| 4 | + | -- the transaction is committed. On crash-recovery redelivery the finalizer | |
| 5 | + | -- re-runs, so the side-effecting writes must be structurally safe to repeat. | |
| 6 | + | -- These two indexes are the backstop that makes a double-mint / double-split | |
| 7 | + | -- impossible even if application-level pre-checks are bypassed. | |
| 8 | + | -- | |
| 9 | + | -- NOTE: both indexes are created NON-CONCURRENTLY (sqlx runs each migration | |
| 10 | + | -- inside its own transaction, and CREATE INDEX CONCURRENTLY cannot run there). | |
| 11 | + | -- Acceptable at current table sizes; revisit if license_keys / revenue_splits | |
| 12 | + | -- grow large enough that the brief write lock matters. | |
| 13 | + | ||
| 14 | + | -- One auto-minted license key per purchase. The partial predicate excludes the | |
| 15 | + | -- manually-created keys (transaction_id IS NULL) that creators issue by hand, | |
| 16 | + | -- so it constrains only purchase-minted keys. | |
| 17 | + | CREATE UNIQUE INDEX IF NOT EXISTS license_keys_transaction_id_key | |
| 18 | + | ON license_keys (transaction_id) | |
| 19 | + | WHERE transaction_id IS NOT NULL; | |
| 20 | + | ||
| 21 | + | -- One revenue split per recipient per transaction. Lets create_transaction_splits | |
| 22 | + | -- use ON CONFLICT DO NOTHING so a finalize re-run is a no-op. | |
| 23 | + | CREATE UNIQUE INDEX IF NOT EXISTS revenue_splits_tx_recipient_key | |
| 24 | + | ON revenue_splits (transaction_id, recipient_id); |
| @@ -93,6 +93,34 @@ pub async fn get_license_key_by_code(pool: &PgPool, key_code: &KeyCode) -> Resul | |||
| 93 | 93 | Ok(key) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | /// Look up the auto-minted license key for a purchase transaction, if any. | |
| 97 | + | /// | |
| 98 | + | /// Used by the finalize pre-check so a crash-recovery redelivery does not mint | |
| 99 | + | /// a second key (the `license_keys_transaction_id_key` partial unique index is | |
| 100 | + | /// the structural backstop). At most one such key exists per transaction. | |
| 101 | + | #[tracing::instrument(skip_all)] | |
| 102 | + | pub async fn get_license_key_by_transaction_id( | |
| 103 | + | pool: &PgPool, | |
| 104 | + | transaction_id: TransactionId, | |
| 105 | + | ) -> Result<Option<DbLicenseKey>> { | |
| 106 | + | let key = sqlx::query_as!( | |
| 107 | + | DbLicenseKey, | |
| 108 | + | r#" | |
| 109 | + | SELECT id AS "id: LicenseKeyId", item_id AS "item_id: ItemId", | |
| 110 | + | owner_id AS "owner_id: UserId", transaction_id AS "transaction_id: TransactionId", | |
| 111 | + | key_code AS "key_code: KeyCode", max_activations, activation_count, | |
| 112 | + | revoked_at AS "revoked_at: chrono::DateTime<chrono::Utc>", | |
| 113 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>" | |
| 114 | + | FROM license_keys WHERE transaction_id = $1 | |
| 115 | + | "#, | |
| 116 | + | transaction_id as TransactionId, | |
| 117 | + | ) | |
| 118 | + | .fetch_optional(pool) | |
| 119 | + | .await?; | |
| 120 | + | ||
| 121 | + | Ok(key) | |
| 122 | + | } | |
| 123 | + | ||
| 96 | 124 | /// Get a license key by ID. | |
| 97 | 125 | #[tracing::instrument(skip_all)] | |
| 98 | 126 | pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Option<DbLicenseKey>> { |
| @@ -248,10 +248,13 @@ pub async fn create_transaction_splits( | |||
| 248 | 248 | let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect(); | |
| 249 | 249 | let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a as i32).collect(); | |
| 250 | 250 | let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect(); | |
| 251 | + | // ON CONFLICT DO NOTHING (against revenue_splits_tx_recipient_key): a | |
| 252 | + | // crash-recovery finalize re-run records no duplicate splits. | |
| 251 | 253 | sqlx::query( | |
| 252 | 254 | r#" | |
| 253 | 255 | INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status) | |
| 254 | 256 | SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending' | |
| 257 | + | ON CONFLICT (transaction_id, recipient_id) DO NOTHING | |
| 255 | 258 | "#, | |
| 256 | 259 | ) | |
| 257 | 260 | .bind(transaction_id) |
| @@ -103,6 +103,9 @@ pub async fn get_key_rotation( | |||
| 103 | 103 | Ok(rotation) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// One sync-log entry awaiting re-encryption: (seq, table_name, row_id, data). | |
| 107 | + | pub type RotationEntry = (i64, String, String, Option<JsonValue>); | |
| 108 | + | ||
| 106 | 109 | /// Pull sync log entries that need re-encryption (key_id != new_key_id). | |
| 107 | 110 | /// Returns entries ordered by seq, paginated by after_seq. | |
| 108 | 111 | #[tracing::instrument(skip_all)] | |
| @@ -113,8 +116,8 @@ pub async fn get_rotation_entries( | |||
| 113 | 116 | new_key_id: i32, | |
| 114 | 117 | after_seq: i64, | |
| 115 | 118 | limit: i64, | |
| 116 | - | ) -> Result<Vec<(i64, String, String, Option<JsonValue>)>> { | |
| 117 | - | let entries: Vec<(i64, String, String, Option<JsonValue>)> = sqlx::query_as( | |
| 119 | + | ) -> Result<Vec<RotationEntry>> { | |
| 120 | + | let entries: Vec<RotationEntry> = sqlx::query_as( | |
| 118 | 121 | r#" | |
| 119 | 122 | SELECT seq, table_name, row_id, data FROM sync_log | |
| 120 | 123 | WHERE app_id = $1 AND user_id = $2 |
| @@ -4,6 +4,7 @@ use sqlx::PgPool; | |||
| 4 | 4 | ||
| 5 | 5 | use super::id_types::*; | |
| 6 | 6 | use super::models::*; | |
| 7 | + | use super::validated_types::Cents; | |
| 7 | 8 | use crate::error::Result; | |
| 8 | 9 | ||
| 9 | 10 | /// Create a pending tip record before redirecting to Stripe Checkout. | |
| @@ -17,19 +18,26 @@ pub async fn create_tip( | |||
| 17 | 18 | message: Option<&str>, | |
| 18 | 19 | stripe_checkout_session_id: &str, | |
| 19 | 20 | ) -> Result<DbTip> { | |
| 20 | - | let tip = sqlx::query_as::<_, DbTip>( | |
| 21 | + | let tip = sqlx::query_as!( | |
| 22 | + | DbTip, | |
| 21 | 23 | r#" | |
| 22 | 24 | INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id) | |
| 23 | 25 | VALUES ($1, $2, $3, $4, $5, $6) | |
| 24 | - | RETURNING * | |
| 26 | + | RETURNING | |
| 27 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 28 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 29 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 30 | + | stripe_transfer_group, | |
| 31 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 32 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 25 | 33 | "#, | |
| 34 | + | tipper_id as UserId, | |
| 35 | + | recipient_id as UserId, | |
| 36 | + | project_id as Option<ProjectId>, | |
| 37 | + | amount_cents, | |
| 38 | + | message, | |
| 39 | + | stripe_checkout_session_id, | |
| 26 | 40 | ) | |
| 27 | - | .bind(tipper_id) | |
| 28 | - | .bind(recipient_id) | |
| 29 | - | .bind(project_id) | |
| 30 | - | .bind(amount_cents) | |
| 31 | - | .bind(message) | |
| 32 | - | .bind(stripe_checkout_session_id) | |
| 33 | 41 | .fetch_one(pool) | |
| 34 | 42 | .await?; | |
| 35 | 43 | ||
| @@ -39,12 +47,13 @@ pub async fn create_tip( | |||
| 39 | 47 | /// Mark a tip as completed after Stripe confirms payment. | |
| 40 | 48 | /// Returns `Some(tip)` if updated, `None` if already completed (idempotent). | |
| 41 | 49 | #[tracing::instrument(skip(executor))] | |
| 42 | - | pub async fn complete_tip( | |
| 43 | - | executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>, | |
| 50 | + | pub async fn complete_tip<'e>( | |
| 51 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 44 | 52 | stripe_checkout_session_id: &str, | |
| 45 | 53 | stripe_payment_intent_id: &str, | |
| 46 | 54 | ) -> Result<Option<DbTip>> { | |
| 47 | - | let tip = sqlx::query_as::<_, DbTip>( | |
| 55 | + | let tip = sqlx::query_as!( | |
| 56 | + | DbTip, | |
| 48 | 57 | r#" | |
| 49 | 58 | UPDATE tips | |
| 50 | 59 | SET status = 'completed', | |
| @@ -52,11 +61,17 @@ pub async fn complete_tip( | |||
| 52 | 61 | completed_at = NOW() | |
| 53 | 62 | WHERE stripe_checkout_session_id = $1 | |
| 54 | 63 | AND status = 'pending' | |
| 55 | - | RETURNING * | |
| 64 | + | RETURNING | |
| 65 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 66 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 67 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 68 | + | stripe_transfer_group, | |
| 69 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 70 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 56 | 71 | "#, | |
| 72 | + | stripe_checkout_session_id, | |
| 73 | + | stripe_payment_intent_id, | |
| 57 | 74 | ) | |
| 58 | - | .bind(stripe_checkout_session_id) | |
| 59 | - | .bind(stripe_payment_intent_id) | |
| 60 | 75 | .fetch_optional(executor) | |
| 61 | 76 | .await?; | |
| 62 | 77 | ||
| @@ -71,10 +86,14 @@ pub async fn get_tips_received( | |||
| 71 | 86 | limit: i64, | |
| 72 | 87 | offset: i64, | |
| 73 | 88 | ) -> Result<Vec<DbTipWithUser>> { | |
| 74 | - | let tips = sqlx::query_as::<_, DbTipWithUser>( | |
| 89 | + | let tips = sqlx::query_as!( | |
| 90 | + | DbTipWithUser, | |
| 75 | 91 | r#" | |
| 76 | - | SELECT t.id, t.tipper_id, t.recipient_id, t.project_id, t.amount_cents, | |
| 77 | - | t.message, t.status, t.created_at, t.completed_at, | |
| 92 | + | SELECT t.id AS "id: TipId", t.tipper_id AS "tipper_id: UserId", t.recipient_id AS "recipient_id: UserId", | |
| 93 | + | t.project_id AS "project_id: ProjectId", t.amount_cents AS "amount_cents: Cents", | |
| 94 | + | t.message, t.status AS "status: super::TransactionStatus", | |
| 95 | + | t.created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 96 | + | t.completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 78 | 97 | u.username AS tipper_username, u.display_name AS tipper_display_name | |
| 79 | 98 | FROM tips t | |
| 80 | 99 | JOIN users u ON u.id = t.tipper_id | |
| @@ -82,10 +101,10 @@ pub async fn get_tips_received( | |||
| 82 | 101 | ORDER BY t.created_at DESC | |
| 83 | 102 | LIMIT $2 OFFSET $3 | |
| 84 | 103 | "#, | |
| 104 | + | recipient_id as UserId, | |
| 105 | + | limit, | |
| 106 | + | offset, | |
| 85 | 107 | ) | |
| 86 | - | .bind(recipient_id) | |
| 87 | - | .bind(limit) | |
| 88 | - | .bind(offset) | |
| 89 | 108 | .fetch_all(pool) | |
| 90 | 109 | .await?; | |
| 91 | 110 | ||
| @@ -95,41 +114,41 @@ pub async fn get_tips_received( | |||
| 95 | 114 | /// Total tip revenue received by a creator (completed tips only). | |
| 96 | 115 | #[tracing::instrument(skip(pool))] | |
| 97 | 116 | pub async fn total_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 98 | - | let row: (Option<i64>,) = sqlx::query_as( | |
| 99 | - | "SELECT SUM(amount_cents)::BIGINT FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 117 | + | let total = sqlx::query_scalar!( | |
| 118 | + | r#"SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "total!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#, | |
| 119 | + | recipient_id as UserId, | |
| 100 | 120 | ) | |
| 101 | - | .bind(recipient_id) | |
| 102 | 121 | .fetch_one(pool) | |
| 103 | 122 | .await?; | |
| 104 | 123 | ||
| 105 | - | Ok(row.0.unwrap_or(0)) | |
| 124 | + | Ok(total) | |
| 106 | 125 | } | |
| 107 | 126 | ||
| 108 | 127 | /// Count of completed tips received. | |
| 109 | 128 | #[tracing::instrument(skip(pool))] | |
| 110 | 129 | pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> { | |
| 111 | - | let row: (i64,) = sqlx::query_as( | |
| 112 | - | "SELECT COUNT(*) FROM tips WHERE recipient_id = $1 AND status = 'completed'", | |
| 130 | + | let count = sqlx::query_scalar!( | |
| 131 | + | r#"SELECT COUNT(*) AS "count!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#, | |
| 132 | + | recipient_id as UserId, | |
| 113 | 133 | ) | |
| 114 | - | .bind(recipient_id) | |
| 115 | 134 | .fetch_one(pool) | |
| 116 | 135 | .await?; | |
| 117 | 136 | ||
| 118 | - | Ok(row.0) | |
| 137 | + | Ok(count) | |
| 119 | 138 | } | |
| 120 | 139 | ||
| 121 | 140 | /// Mark a tip as refunded by payment intent ID. | |
| 122 | 141 | /// Returns true if a tip was refunded, false if not found (idempotent). | |
| 123 | 142 | #[tracing::instrument(skip(pool))] | |
| 124 | 143 | pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> { | |
| 125 | - | let result = sqlx::query( | |
| 144 | + | let result = sqlx::query!( | |
| 126 | 145 | r#" | |
| 127 | 146 | UPDATE tips | |
| 128 | 147 | SET status = 'refunded' | |
| 129 | 148 | WHERE stripe_payment_intent_id = $1 AND status = 'completed' | |
| 130 | 149 | "#, | |
| 150 | + | payment_intent_id, | |
| 131 | 151 | ) | |
| 132 | - | .bind(payment_intent_id) | |
| 133 | 152 | .execute(pool) | |
| 134 | 153 | .await?; | |
| 135 | 154 | ||
| @@ -145,17 +164,25 @@ pub async fn get_tips_sent( | |||
| 145 | 164 | limit: i64, | |
| 146 | 165 | offset: i64, | |
| 147 | 166 | ) -> Result<Vec<DbTip>> { | |
| 148 | - | let tips = sqlx::query_as::<_, DbTip>( | |
| 167 | + | let tips = sqlx::query_as!( | |
| 168 | + | DbTip, | |
| 149 | 169 | r#" | |
| 150 | - | SELECT * FROM tips | |
| 170 | + | SELECT | |
| 171 | + | id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId", | |
| 172 | + | project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message, | |
| 173 | + | status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 174 | + | stripe_transfer_group, | |
| 175 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", | |
| 176 | + | completed_at AS "completed_at: chrono::DateTime<chrono::Utc>" | |
| 177 | + | FROM tips | |
| 151 | 178 | WHERE tipper_id = $1 AND status = 'completed' | |
| 152 | 179 | ORDER BY created_at DESC | |
| 153 | 180 | LIMIT $2 OFFSET $3 | |
| 154 | 181 | "#, | |
| 182 | + | tipper_id as UserId, | |
| 183 | + | limit, | |
| 184 | + | offset, | |
| 155 | 185 | ) | |
| 156 | - | .bind(tipper_id) | |
| 157 | - | .bind(limit) | |
| 158 | - | .bind(offset) | |
| 159 | 186 | .fetch_all(pool) | |
| 160 | 187 | .await?; | |
| 161 | 188 |
| @@ -292,6 +292,41 @@ pub async fn complete_cart_transactions<'e>( | |||
| 292 | 292 | Ok(txs) | |
| 293 | 293 | } | |
| 294 | 294 | ||
| 295 | + | /// Fetch all completed transactions for a checkout session. | |
| 296 | + | /// | |
| 297 | + | /// Used on the crash-recovery branch of the purchase/cart webhook handlers: when | |
| 298 | + | /// `complete_transaction` / `complete_cart_transactions` return nothing (the | |
| 299 | + | /// rows were already flipped to completed by a first attempt that crashed before | |
| 300 | + | /// running finalize), this re-reads those completed rows so finalize can re-run | |
| 301 | + | /// idempotently. Covers single and cart purchases since both key on the session. | |
| 302 | + | #[tracing::instrument(skip_all)] | |
| 303 | + | pub async fn get_completed_transactions_for_session<'e>( | |
| 304 | + | executor: impl sqlx::PgExecutor<'e>, | |
| 305 | + | stripe_checkout_session_id: &str, | |
| 306 | + | ) -> Result<Vec<DbTransaction>> { | |
| 307 | + | let txs = sqlx::query_as!( | |
| 308 | + | DbTransaction, | |
| 309 | + | r#" | |
| 310 | + | SELECT | |
| 311 | + | id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId", | |
| 312 | + | item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents", | |
| 313 | + | currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id, | |
| 314 | + | created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>", | |
| 315 | + | item_title, seller_username, share_contact, project_id AS "project_id: ProjectId", | |
| 316 | + | parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId", | |
| 317 | + | guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId", | |
| 318 | + | download_token AS "download_token: DownloadToken" | |
| 319 | + | FROM transactions | |
| 320 | + | WHERE stripe_checkout_session_id = $1 AND status = 'completed' | |
| 321 | + | "#, | |
| 322 | + | stripe_checkout_session_id, | |
| 323 | + | ) | |
| 324 | + | .fetch_all(executor) | |
| 325 | + | .await?; | |
| 326 | + | ||
| 327 | + | Ok(txs) | |
| 328 | + | } | |
| 329 | + | ||
| 295 | 330 | /// List transactions where the user is the buyer, newest first. | |
| 296 | 331 | /// | |
| 297 | 332 | /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display. |
| @@ -304,35 +304,6 @@ async fn request_timeout_middleware( | |||
| 304 | 304 | } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | - | #[cfg(test)] | |
| 308 | - | mod timeout_exempt_tests { | |
| 309 | - | use super::timeout_exempt; | |
| 310 | - | ||
| 311 | - | #[test] | |
| 312 | - | fn exempts_only_anchored_long_running_routes() { | |
| 313 | - | // Genuinely long / streaming routes stay exempt. | |
| 314 | - | for p in [ | |
| 315 | - | "/git/foo/bar.git/info/refs", | |
| 316 | - | "/api/export/content", | |
| 317 | - | "/api/internal/creator/export/sales", | |
| 318 | - | "/api/sync/subscribe", | |
| 319 | - | "/api/v1/sync/subscribe", | |
| 320 | - | "/api/sync/ota/apps/x/releases", | |
| 321 | - | "/api/v1/sync/ota/slug/macos/arm64/1.0.0", | |
| 322 | - | ] { | |
| 323 | - | assert!(timeout_exempt(p), "{p} should be exempt"); | |
| 324 | - | } | |
| 325 | - | // The substring-match hazard: these contain a token but must NOT be exempt. | |
| 326 | - | for p in [ | |
| 327 | - | "/dashboard/export", // a normal page, not a long export | |
| 328 | - | "/u/somecreator/export-notes", // creator-controlled slug | |
| 329 | - | "/items/sync/ota-recap", // happens to contain the token mid-path | |
| 330 | - | ] { | |
| 331 | - | assert!(!timeout_exempt(p), "{p} must not be exempt"); | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | ||
| 336 | 307 | /// Middleware that sets security headers on all responses. | |
| 337 | 308 | /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding. | |
| 338 | 309 | async fn security_headers_middleware( | |
| @@ -429,3 +400,32 @@ async fn security_headers_middleware( | |||
| 429 | 400 | ); | |
| 430 | 401 | response | |
| 431 | 402 | } | |
| 403 | + | ||
| 404 | + | #[cfg(test)] | |
| 405 | + | mod timeout_exempt_tests { | |
| 406 | + | use super::timeout_exempt; | |
| 407 | + | ||
| 408 | + | #[test] | |
| 409 | + | fn exempts_only_anchored_long_running_routes() { | |
| 410 | + | // Genuinely long / streaming routes stay exempt. | |
| 411 | + | for p in [ | |
| 412 | + | "/git/foo/bar.git/info/refs", | |
| 413 | + | "/api/export/content", | |
| 414 | + | "/api/internal/creator/export/sales", | |
| 415 | + | "/api/sync/subscribe", | |
| 416 | + | "/api/v1/sync/subscribe", | |
| 417 | + | "/api/sync/ota/apps/x/releases", | |
| 418 | + | "/api/v1/sync/ota/slug/macos/arm64/1.0.0", | |
| 419 | + | ] { | |
| 420 | + | assert!(timeout_exempt(p), "{p} should be exempt"); | |
| 421 | + | } | |
| 422 | + | // The substring-match hazard: these contain a token but must NOT be exempt. | |
| 423 | + | for p in [ | |
| 424 | + | "/dashboard/export", // a normal page, not a long export | |
| 425 | + | "/u/somecreator/export-notes", // creator-controlled slug | |
| 426 | + | "/items/sync/ota-recap", // happens to contain the token mid-path | |
| 427 | + | ] { | |
| 428 | + | assert!(!timeout_exempt(p), "{p} must not be exempt"); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | } |
| @@ -9,9 +9,8 @@ use crate::{ | |||
| 9 | 9 | }; | |
| 10 | 10 | ||
| 11 | 11 | use super::checkout_helpers::{ | |
| 12 | - | check_pending_refund, maybe_generate_license_key, record_tip_splits, | |
| 13 | - | record_transaction_splits, send_guest_sale_notification, send_purchase_emails, | |
| 14 | - | send_tip_email, subscribe_buyer_to_mailing_list, | |
| 12 | + | check_pending_refund, finalize_guest_transaction, finalize_purchase_transaction, | |
| 13 | + | record_tip_splits, send_tip_email, | |
| 15 | 14 | }; | |
| 16 | 15 | ||
| 17 | 16 | /// A `checkout.session.completed` produced no transaction to complete. Tell a | |
| @@ -131,29 +130,9 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 131 | 130 | db_tx.commit().await.context("commit purchase webhook transaction")?; | |
| 132 | 131 | ||
| 133 | 132 | // --- Secondary effects below (outside transaction) --- | |
| 134 | - | ||
| 135 | - | // Grant access to bundle child items (if this is a bundle) | |
| 136 | - | if let Some(iid) = item_id | |
| 137 | - | && let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, iid).await | |
| 138 | - | && purchased_item.item_type == db::ItemType::Bundle | |
| 139 | - | { | |
| 140 | - | crate::routes::stripe::checkout::grant_bundle_items(state, iid, buyer_id, seller_id, Some(tx.id)).await; | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | if tx.share_contact { | |
| 144 | - | db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id) | |
| 145 | - | .await | |
| 146 | - | .context("clear contact revocation after purchase")?; | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Record revenue splits if the item's project has members | |
| 150 | - | if let Some(iid) = item_id { | |
| 151 | - | record_transaction_splits(state, tx.id, iid, tx.amount_cents).await; | |
| 152 | - | maybe_generate_license_key(state, iid, buyer_id, tx.id).await; | |
| 153 | - | subscribe_buyer_to_mailing_list(state, iid, buyer_id); | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | send_purchase_emails(state, &tx, buyer_id, seller_id); | |
| 133 | + | // Consolidated in one re-runnable finalizer so the purchase and cart | |
| 134 | + | // paths can't drift and a crash-recovery redelivery re-runs safely. | |
| 135 | + | finalize_purchase_transaction(state, &tx, buyer_id, seller_id).await; | |
| 157 | 136 | ||
| 158 | 137 | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 159 | 138 | &state.db, None, event_id, "checkout.session.completed.purchase", | |
| @@ -167,7 +146,27 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 167 | 146 | check_pending_refund(state, &payment_intent_id).await; | |
| 168 | 147 | } | |
| 169 | 148 | Ok(None) => { | |
| 170 | - | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "transaction").await?; | |
| 149 | + | // No row flipped to completed. Either this is a benign duplicate of | |
| 150 | + | // an already-finalized purchase, OR the first attempt crashed AFTER | |
| 151 | + | // committing the completed status but BEFORE running finalize — in | |
| 152 | + | // which case the buyer holds a completed purchase with no license | |
| 153 | + | // key / no splits. Re-fetch completed rows for the session and | |
| 154 | + | // re-run the idempotent finalizer; only escalate if none exist | |
| 155 | + | // (genuinely orphaned: payment took, rows never created). | |
| 156 | + | let completed = db::transactions::get_completed_transactions_for_session(&state.db, &session_id) | |
| 157 | + | .await | |
| 158 | + | .context("re-fetch completed transactions for crash recovery")?; | |
| 159 | + | if completed.is_empty() { | |
| 160 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "transaction").await?; | |
| 161 | + | } else { | |
| 162 | + | for tx in &completed { | |
| 163 | + | tracing::info!( | |
| 164 | + | session_id = %session_id, transaction_id = %tx.id, | |
| 165 | + | "crash-recovery: re-running finalize for already-completed session" | |
| 166 | + | ); | |
| 167 | + | finalize_purchase_transaction(state, tx, buyer_id, seller_id).await; | |
| 168 | + | } | |
| 169 | + | } | |
| 171 | 170 | } | |
| 172 | 171 | Err(e) => { | |
| 173 | 172 | tracing::error!(session_id = %session_id, error = ?e, "failed to complete transaction"); | |
| @@ -204,7 +203,24 @@ pub(super) async fn handle_cart_checkout_completed( | |||
| 204 | 203 | .context("complete cart transactions")?; | |
| 205 | 204 | ||
| 206 | 205 | if completed_txs.is_empty() { | |
| 207 | - | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "cart transactions").await?; | |
| 206 | + | // Same crash-recovery shape as the single-item handler: re-fetch | |
| 207 | + | // completed rows for the session and re-run the idempotent finalizer | |
| 208 | + | // before falling through to orphan escalation. | |
| 209 | + | db_tx.commit().await.ok(); | |
| 210 | + | let completed = db::transactions::get_completed_transactions_for_session(&state.db, &session_id) | |
| 211 | + | .await | |
| 212 | + | .context("re-fetch completed cart transactions for crash recovery")?; | |
| 213 | + | if completed.is_empty() { | |
| 214 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "cart transactions").await?; | |
| 215 | + | } else { | |
| 216 | + | for tx in &completed { | |
| 217 | + | tracing::info!( | |
| 218 | + | session_id = %session_id, transaction_id = %tx.id, | |
| 219 | + | "crash-recovery: re-running finalize for already-completed cart session" | |
| 220 | + | ); | |
| 221 | + | finalize_purchase_transaction(state, tx, buyer_id, seller_id).await; | |
| 222 | + | } | |
| 223 | + | } | |
| 208 | 224 | return Ok(()); | |
| 209 | 225 | } | |
| 210 | 226 | ||
| @@ -257,40 +273,12 @@ pub(super) async fn handle_cart_checkout_completed( | |||
| 257 | 273 | .context("remove cart items after successful payment")?; | |
| 258 | 274 | ||
| 259 | 275 | // --- Secondary effects (outside transaction) --- | |
| 260 | - | ||
| 261 | - | for tx in &completed_txs { | |
| 262 | - | if let Some(item_id) = tx.item_id { | |
| 263 | - | // Bundle grants | |
| 264 | - | if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await | |
| 265 | - | && purchased_item.item_type == db::ItemType::Bundle | |
| 266 | - | { | |
| 267 | - | crate::routes::stripe::checkout::grant_bundle_items( | |
| 268 | - | state, item_id, buyer_id, seller_id, Some(tx.id), | |
| 269 | - | ) | |
| 270 | - | .await; | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | // Revenue splits | |
| 274 | - | record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await; | |
| 275 | - | ||
| 276 | - | // License keys | |
| 277 | - | maybe_generate_license_key(state, item_id, buyer_id, tx.id).await; | |
| 278 | - | ||
| 279 | - | // Mailing list | |
| 280 | - | subscribe_buyer_to_mailing_list(state, item_id, buyer_id); | |
| 281 | - | } | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | // Contact sharing (once per seller) | |
| 285 | - | if completed_txs.iter().any(|t| t.share_contact) { | |
| 286 | - | db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id) | |
| 287 | - | .await | |
| 288 | - | .context("clear contact revocation after cart purchase")?; | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // Send purchase emails for each item (reuse existing per-item emails) | |
| 276 | + | // One re-runnable finalizer per transaction; shared with the single-item | |
| 277 | + | // path so the effect blocks can't drift. Idempotent on a crash-recovery | |
| 278 | + | // redelivery. (clear_contact_revocation runs per-tx that opted in, which is | |
| 279 | + | // a no-op once already cleared.) | |
| 292 | 280 | for tx in &completed_txs { | |
| 293 | - | send_purchase_emails(state, tx, buyer_id, seller_id); | |
| 281 | + | finalize_purchase_transaction(state, tx, buyer_id, seller_id).await; | |
| 294 | 282 | } | |
| 295 | 283 | ||
| 296 | 284 | // Log event | |
| @@ -722,38 +710,30 @@ pub(super) async fn handle_guest_checkout_completed( | |||
| 722 | 710 | db_tx.commit().await.context("commit guest checkout webhook transaction")?; | |
| 723 | 711 | ||
| 724 | 712 | // --- Secondary effects below (outside transaction) --- | |
| 725 | - | ||
| 726 | - | // A guest purchase is never auto-attached, so there's no buyer yet: | |
| 727 | - | // the license key (if any) is minted when the buyer claims, in | |
| 728 | - | // `claim_purchase`. Revenue splits are recorded now regardless. | |
| 729 | - | record_transaction_splits(state, tx.id, meta.item_id, tx.amount_cents).await; | |
| 730 | - | ||
| 731 | - | // Always send the guest purchase confirmation with the claim link. | |
| 732 | - | if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) | |
| 733 | - | { | |
| 734 | - | let email_client = state.email.clone(); | |
| 735 | - | let host_url = state.config.host_url.clone(); | |
| 736 | - | let item_title = tx.item_title.clone().unwrap_or_else(|| "your item".to_string()); | |
| 737 | - | let price = helpers::format_price(tx.amount_cents); | |
| 738 | - | let guest_email_addr = guest_email.clone(); | |
| 739 | - | let download_url = format!("{}/download/{}", host_url, download_token); | |
| 740 | - | let claim_url = format!("{}/claim?token={}", host_url, claim_token); | |
| 741 | - | ||
| 742 | - | state.bg.spawn("guest purchase confirmation", async move { | |
| 743 | - | if let Err(e) = email_client.send_guest_purchase_confirmation( | |
| 744 | - | &guest_email_addr, &item_title, &price, &download_url, &claim_url, | |
| 745 | - | ).await { | |
| 746 | - | tracing::error!(error = ?e, "failed to send guest purchase confirmation email"); | |
| 747 | - | } | |
| 748 | - | }); | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | // Send sale notification to seller | |
| 752 | - | send_guest_sale_notification(state, &tx, &guest_email, meta.seller_id); | |
| 713 | + | // One re-runnable finalizer (splits + confirmation + sale email). | |
| 714 | + | finalize_guest_transaction(state, &tx, &guest_email, meta.item_id, meta.seller_id); | |
| 753 | 715 | } | |
| 754 | 716 | None => { | |
| 755 | 717 | db_tx.commit().await.ok(); | |
| 756 | - | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "guest transaction").await?; | |
| 718 | + | // Crash-recovery: re-fetch completed guest rows for the session and | |
| 719 | + | // re-run the idempotent finalizer; only escalate if none exist. | |
| 720 | + | let completed = db::transactions::get_completed_transactions_for_session(&state.db, &session_id) | |
| 721 | + | .await | |
| 722 | + | .context("re-fetch completed guest transactions for crash recovery")?; | |
| 723 | + | if completed.is_empty() { | |
| 724 | + | escalate_if_orphaned_session(state, &session_id, &payment_intent_id, "guest transaction").await?; | |
| 725 | + | } else { | |
| 726 | + | for tx in &completed { | |
| 727 | + | let guest_email = tx.guest_email.clone().unwrap_or_else(|| guest_email.clone()); | |
| 728 | + | tracing::info!( | |
| 729 | + | session_id = %session_id, transaction_id = %tx.id, | |
| 730 | + | "crash-recovery: re-running finalize for already-completed guest session" | |
| 731 | + | ); | |
| 732 | + | if let Some(item_id) = tx.item_id { | |
| 733 | + | finalize_guest_transaction(state, tx, &guest_email, item_id, meta.seller_id); | |
| 734 | + | } | |
| 735 | + | } | |
| 736 | + | } | |
| 757 | 737 | } | |
| 758 | 738 | } | |
| 759 | 739 |
| @@ -19,6 +19,22 @@ pub(crate) async fn maybe_generate_license_key( | |||
| 19 | 19 | _ => return, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | + | // Idempotency pre-check: a crash-recovery redelivery re-runs finalize, but a | |
| 23 | + | // purchase mints at most one auto key. If one already exists for this | |
| 24 | + | // transaction, skip the mint. The `license_keys_transaction_id_key` partial | |
| 25 | + | // unique index is the structural backstop if this check is ever bypassed. | |
| 26 | + | match db::license_keys::get_license_key_by_transaction_id(&state.db, transaction_id).await { | |
| 27 | + | Ok(Some(_)) => { | |
| 28 | + | tracing::debug!(transaction_id = %transaction_id, item_id = %item_id, "license key already minted for transaction; skipping"); | |
| 29 | + | return; | |
| 30 | + | } | |
| 31 | + | Ok(None) => {} | |
| 32 | + | Err(e) => { | |
| 33 | + | tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to check for existing license key; skipping mint to avoid duplicate"); | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 22 | 38 | let key_code = helpers::generate_key_code(); | |
| 23 | 39 | match db::license_keys::create_license_key( | |
| 24 | 40 | &state.db, item_id, buyer_id, Some(transaction_id), | |
| @@ -41,6 +57,50 @@ pub(crate) async fn maybe_generate_license_key( | |||
| 41 | 57 | } | |
| 42 | 58 | } | |
| 43 | 59 | ||
| 60 | + | /// Run every secondary effect of a completed (logged-in) purchase, in order: | |
| 61 | + | /// bundle grants, contact-revocation clear, revenue splits, license-key mint, | |
| 62 | + | /// mailing-list subscribe, and the purchase/sale emails. | |
| 63 | + | /// | |
| 64 | + | /// This is the single place the purchase and cart handlers funnel their effect | |
| 65 | + | /// blocks through, so the two can't drift, and it is safe to re-run: a | |
| 66 | + | /// crash-recovery redelivery (transaction already completed, event not yet | |
| 67 | + | /// marked processed) re-invokes it. Every DB effect here is now idempotent | |
| 68 | + | /// (ON CONFLICT writes or a pre-check guarded by a unique index). The fire-and- | |
| 69 | + | /// forget emails may re-send on that rare redelivery; that is acceptable and | |
| 70 | + | /// consistent with the existing webhook architecture (handlers are idempotent | |
| 71 | + | /// on data, best-effort on notifications). | |
| 72 | + | pub(super) async fn finalize_purchase_transaction( | |
| 73 | + | state: &AppState, | |
| 74 | + | tx: &db::DbTransaction, | |
| 75 | + | buyer_id: db::UserId, | |
| 76 | + | seller_id: db::UserId, | |
| 77 | + | ) { | |
| 78 | + | // Grant access to bundle child items (if this purchase is a bundle). | |
| 79 | + | if let Some(item_id) = tx.item_id | |
| 80 | + | && let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await | |
| 81 | + | && purchased_item.item_type == db::ItemType::Bundle | |
| 82 | + | { | |
| 83 | + | crate::routes::stripe::checkout::grant_bundle_items(state, item_id, buyer_id, seller_id, Some(tx.id)).await; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Contact-revocation clear (if the buyer opted to share contact). | |
| 87 | + | if tx.share_contact | |
| 88 | + | && let Err(e) = db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await | |
| 89 | + | { | |
| 90 | + | tracing::error!(transaction_id = %tx.id, error = ?e, "failed to clear contact revocation after purchase"); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Revenue splits, license key, mailing list (each keyed to the item). | |
| 94 | + | if let Some(item_id) = tx.item_id { | |
| 95 | + | record_transaction_splits(state, tx.id, item_id, tx.amount_cents).await; | |
| 96 | + | maybe_generate_license_key(state, item_id, buyer_id, tx.id).await; | |
| 97 | + | subscribe_buyer_to_mailing_list(state, item_id, buyer_id); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Purchase confirmation + sale notification (fire-and-forget). | |
| 101 | + | send_purchase_emails(state, tx, buyer_id, seller_id); | |
| 102 | + | } | |
| 103 | + | ||
| 44 | 104 | /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget). | |
| 45 | 105 | pub(super) fn send_purchase_emails( | |
| 46 | 106 | state: &AppState, | |
| @@ -287,6 +347,53 @@ fn compute_splits( | |||
| 287 | 347 | splits | |
| 288 | 348 | } | |
| 289 | 349 | ||
| 350 | + | /// Run every secondary effect of a completed guest purchase, in order: revenue | |
| 351 | + | /// splits, the guest purchase confirmation (with claim + download links), and | |
| 352 | + | /// the seller sale notification. | |
| 353 | + | /// | |
| 354 | + | /// Mirrors [`finalize_purchase_transaction`] but for the guest path, which has | |
| 355 | + | /// no buyer account yet (the license key, if any, is minted at claim time in | |
| 356 | + | /// `claim_purchase`, not here). Re-runnable on a crash-recovery redelivery: | |
| 357 | + | /// splits go through an ON CONFLICT write and the emails are fire-and-forget | |
| 358 | + | /// (a re-send on that rare redelivery is acceptable). | |
| 359 | + | pub(super) fn finalize_guest_transaction( | |
| 360 | + | state: &AppState, | |
| 361 | + | tx: &db::DbTransaction, | |
| 362 | + | guest_email: &str, | |
| 363 | + | item_id: db::ItemId, | |
| 364 | + | seller_id: db::UserId, | |
| 365 | + | ) { | |
| 366 | + | // Revenue splits (idempotent). | |
| 367 | + | let state_for_splits = state.clone(); | |
| 368 | + | let tx_id = tx.id; | |
| 369 | + | let amount_cents = tx.amount_cents; | |
| 370 | + | state.bg.spawn("guest revenue splits", async move { | |
| 371 | + | record_transaction_splits(&state_for_splits, tx_id, item_id, amount_cents).await; | |
| 372 | + | }); | |
| 373 | + | ||
| 374 | + | // Guest purchase confirmation with the claim link (fire-and-forget). | |
| 375 | + | if let (Some(download_token), Some(claim_token)) = (tx.download_token, tx.claim_token) { | |
| 376 | + | let email_client = state.email.clone(); | |
| 377 | + | let host_url = state.config.host_url.clone(); | |
| 378 | + | let item_title = tx.item_title.clone().unwrap_or_else(|| "your item".to_string()); | |
| 379 | + | let price = helpers::format_price(tx.amount_cents); | |
| 380 | + | let guest_email_addr = guest_email.to_string(); | |
| 381 | + | let download_url = format!("{}/download/{}", host_url, download_token); | |
| 382 | + | let claim_url = format!("{}/claim?token={}", host_url, claim_token); | |
| 383 | + | ||
| 384 | + | state.bg.spawn("guest purchase confirmation", async move { | |
| 385 | + | if let Err(e) = email_client.send_guest_purchase_confirmation( | |
| 386 | + | &guest_email_addr, &item_title, &price, &download_url, &claim_url, | |
| 387 | + | ).await { | |
| 388 | + | tracing::error!(error = ?e, "failed to send guest purchase confirmation email"); | |
| 389 | + | } | |
| 390 | + | }); | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // Sale notification to the seller (fire-and-forget). | |
| 394 | + | send_guest_sale_notification(state, tx, guest_email, seller_id); | |
| 395 | + | } | |
| 396 | + | ||
| 290 | 397 | /// Send sale notification to the seller for a guest purchase. | |
| 291 | 398 | pub(super) fn send_guest_sale_notification( | |
| 292 | 399 | state: &AppState, |
| @@ -258,23 +258,36 @@ async fn sql_injection_in_title_harmless() { | |||
| 258 | 258 | // Duplicate creation | |
| 259 | 259 | // ============================================================================= | |
| 260 | 260 | ||
| 261 | - | /// Vulnerability tested: Duplicate project slug creates confusion or overwrites. | |
| 262 | - | /// Second project with same slug should be rejected (unique constraint). | |
| 261 | + | /// Vulnerability tested: a duplicate project slug must never overwrite the | |
| 262 | + | /// existing project or surface a raw 500. The create path auto-suffixes the | |
| 263 | + | /// collision (`inputtest-proj` -> `inputtest-proj-2`) via `insert_with_unique_slug` | |
| 264 | + | /// and retries — the deliberate Run 2 UX seal. The second project gets a fresh | |
| 265 | + | /// id and a distinct slug, so no confusion or overwrite is possible. | |
| 263 | 266 | #[tokio::test] | |
| 264 | - | async fn duplicate_project_slug_rejected() { | |
| 267 | + | async fn duplicate_project_slug_auto_suffixed() { | |
| 265 | 268 | let mut h = TestHarness::new().await; | |
| 266 | - | let (_project_id, _item_id) = setup_creator_with_item(&mut h).await; | |
| 269 | + | let (project_id, _item_id) = setup_creator_with_item(&mut h).await; | |
| 267 | 270 | ||
| 268 | - | // Try creating another project with the same slug | |
| 271 | + | // Creating another project with the same slug succeeds with a suffixed slug. | |
| 269 | 272 | let resp = h | |
| 270 | 273 | .client | |
| 271 | 274 | .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop") | |
| 272 | 275 | .await; | |
| 273 | 276 | assert!( | |
| 274 | - | !resp.status.is_success(), | |
| 275 | - | "Duplicate slug must not succeed: {} {}", | |
| 277 | + | resp.status.is_success(), | |
| 278 | + | "Duplicate slug should auto-suffix, not fail: {} {}", | |
| 276 | 279 | resp.status, resp.text | |
| 277 | 280 | ); | |
| 281 | + | let second: serde_json::Value = resp.json(); | |
| 282 | + | assert_eq!( | |
| 283 | + | second["slug"], "inputtest-proj-2", | |
| 284 | + | "collision must auto-suffix to inputtest-proj-2: {}", second | |
| 285 | + | ); | |
| 286 | + | assert_ne!( | |
| 287 | + | second["id"].as_str().unwrap(), | |
| 288 | + | project_id, | |
| 289 | + | "the duplicate must be a new project, never an overwrite" | |
| 290 | + | ); | |
| 278 | 291 | } | |
| 279 | 292 | ||
| 280 | 293 | /// Vulnerability tested: Duplicate username on signup. |
| @@ -84,6 +84,7 @@ mod imports; | |||
| 84 | 84 | mod media_library; | |
| 85 | 85 | mod synckit_sse; | |
| 86 | 86 | mod mock_payment_flows; | |
| 87 | + | mod payment_crash_recovery; | |
| 87 | 88 | mod revenue_splits; | |
| 88 | 89 | mod synckit_selective; | |
| 89 | 90 | mod guest_checkout; |