diff --git a/package-lock.json b/package-lock.json index 9715e3b..d5f7bd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,7 +131,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -141,7 +141,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -151,7 +151,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -176,7 +176,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -243,7 +243,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1078,6 +1078,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1089,6 +1090,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1096,6 +1098,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1106,6 +1109,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1113,6 +1117,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1123,6 +1128,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1133,6 +1139,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,6 +1156,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1165,6 +1173,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1181,6 +1190,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1197,6 +1207,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1213,6 +1224,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1229,6 +1241,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1245,6 +1258,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1261,6 +1275,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1277,6 +1292,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1293,6 +1309,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1309,6 +1326,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1325,6 +1343,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1341,6 +1360,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1357,6 +1377,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1373,6 +1394,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1389,6 +1411,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1422,6 +1445,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1455,6 +1479,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1488,6 +1513,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1504,6 +1530,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1520,6 +1547,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1536,6 +1564,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2298,6 +2327,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4113,6 +4143,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4129,6 +4160,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4145,6 +4177,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4161,6 +4194,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4177,6 +4211,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4193,6 +4228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4209,6 +4245,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4225,6 +4262,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4241,6 +4279,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4273,6 +4312,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4289,6 +4329,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4305,6 +4346,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4323,6 +4365,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4339,6 +4382,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4844,6 +4888,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4854,6 +4899,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -5028,7 +5074,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", @@ -5276,7 +5322,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -5288,7 +5334,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -6042,7 +6088,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6087,6 +6133,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6103,6 +6150,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6119,6 +6167,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6467,6 +6516,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6659,7 +6709,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6744,7 +6794,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/html-void-elements": { @@ -6895,7 +6945,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -6905,7 +6955,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -6920,7 +6970,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -7060,6 +7110,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7080,6 +7131,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7100,6 +7152,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7120,6 +7173,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7140,6 +7194,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7160,6 +7215,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7180,6 +7236,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7200,6 +7257,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7220,6 +7278,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7240,6 +7299,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7260,6 +7320,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7352,7 +7413,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -7364,7 +7425,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -8606,7 +8667,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8983,7 +9044,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" diff --git a/src/workers/account.ts b/src/workers/account.ts index d38075b..9e6731d 100644 --- a/src/workers/account.ts +++ b/src/workers/account.ts @@ -390,38 +390,44 @@ accountRoutes.get('/api/account/polar/status', async (c) => { }); }); -// GET /api/account/earnings — returns the creator's payout summary for the -// current calendar year. Numbers are null until the Polar payout webhook -// integration lands (ALO-partner-tax gap): Polar is MoR for sales-tax/VAT but -// does not yet issue 1099-K / 1099-MISC for US creator partners. The -// `taxDocStatus` field drives the gap banner in AccountSettings. +// GET /api/account/earnings — returns the creator's payout summary pulled from +// the D1 ledger that the Polar webhook handler writes to. The `taxDocStatus` +// field drives the gap banner in AccountSettings. accountRoutes.get('/api/account/earnings', async (c) => { const user = c.get('user'); if (!user) return c.json({ error: 'Unauthorized' }, 401); - const row = await c.env.DB.prepare( - `SELECT polar_organization_id, polar_account_status FROM user WHERE id = ?`, - ) - .bind(user.id) - .first<{ polar_organization_id: string | null; polar_account_status: string }>(); - - const polarStatus = (row?.polar_account_status ?? 'not_connected') as PolarAccountStatus; + const [userRow, earningsRow, payoutsRow] = await Promise.all([ + c.env.DB.prepare( + `SELECT polar_organization_id, polar_account_status FROM user WHERE id = ?`, + ) + .bind(user.id) + .first<{ polar_organization_id: string | null; polar_account_status: string }>(), + c.env.DB.prepare( + `SELECT COALESCE(SUM(amount_cents), 0) AS gross FROM creator_earnings WHERE user_id = ?`, + ) + .bind(user.id) + .first<{ gross: number }>(), + c.env.DB.prepare( + `SELECT COALESCE(SUM(amount_cents), 0) AS paid FROM creator_payouts WHERE user_id = ? AND status = 'paid'`, + ) + .bind(user.id) + .first<{ paid: number }>(), + ]); - // ALO-TODO: replace grossEarningsUsd / netPayoutsUsd with real Polar payout - // webhook aggregation once the partner-payout integration is live. + const polarStatus = (userRow?.polar_account_status ?? 'not_connected') as PolarAccountStatus; const year = new Date().getUTCFullYear(); + return c.json({ year, currency: 'USD', - // Gross earnings before Spooool's 10% platform fee and Polar processing fees. - grossEarningsUsd: null as number | null, - // Net creator payout — what Polar actually transfers to the creator's bank. - netPayoutsUsd: null as number | null, + grossEarningsUsd: earningsRow != null ? earningsRow.gross / 100 : null, + netPayoutsUsd: payoutsRow != null ? payoutsRow.paid / 100 : null, // 'polar-pending' = Polar has not yet issued 1099 forms for creator partners. // Update to 'polar-issues' once Polar confirms 1099-K / 1099-MISC delivery. taxDocStatus: 'polar-pending' as 'polar-pending' | 'polar-issues', polar: { - organizationId: row?.polar_organization_id ?? null, + organizationId: userRow?.polar_organization_id ?? null, accountStatus: polarStatus, needsOnboarding: polarStatus !== 'active', }, diff --git a/src/workers/polar-webhook.test.ts b/src/workers/polar-webhook.test.ts index 5456d91..148e20c 100644 --- a/src/workers/polar-webhook.test.ts +++ b/src/workers/polar-webhook.test.ts @@ -47,6 +47,7 @@ function makeFakeDB(seed: FakeLedgerRow[] = []): { bound = values; return stmt; }, + async first() { return null; }, async run() { // INSERT OR IGNORE: skip if webhook_id already present. const [id, webhook_id, event_type, polar_object_id, polar_customer_id, @@ -447,3 +448,272 @@ describe('handlePolarWebhook', () => { expect(JSON.parse(rows[0].meta_json).metadata).toMatchObject({ video_id: 42, gift: true }); }); }); + +// --------------------------------------------------------------------------- +// processEarningsEvent — secondary writes (creator_earnings / creator_payouts) +// --------------------------------------------------------------------------- +// +// These tests use a full fake DB that separates ledger rows from earnings and +// payout rows so we can assert the secondary writes independently. + +interface FakeEarningsRow { + id: string; + user_id: string; + kind: string; + amount_cents: number; + platform_fee_cents: number; + currency: string; + polar_order_id: string | null; + description: string | null; + created_at: number; +} + +interface FakePayoutRow { + id: string; + user_id: string; + amount_cents: number; + currency: string; + polar_payout_id: string | null; + status: string; + paid_at: number | null; + created_at: number; +} + +function makeFullFakeDB(opts: { userByPolarAccountId?: { id: string } | null } = {}): { + ledgerRows: FakeLedgerRow[]; + earningsRows: FakeEarningsRow[]; + payoutRows: FakePayoutRow[]; + binding: D1Database; +} { + const { userByPolarAccountId = null } = opts; + const ledgerRows: FakeLedgerRow[] = []; + const earningsRows: FakeEarningsRow[] = []; + const payoutRows: FakePayoutRow[] = []; + + const db = { + prepare(sql: string) { + let bound: unknown[] = []; + const stmt = { + bind(...values: unknown[]) { bound = values; return stmt; }, + async first() { + if (sql.includes('polar_account_id') && sql.includes('FROM user')) { + return userByPolarAccountId; + } + return null; + }, + async run() { + if (sql.includes('polar_ledger')) { + const [id, webhook_id, event_type, polar_object_id, polar_customer_id, + polar_user_id, amount_cents, currency, status, meta_json, created_at] = + bound as [string, string, string, string | null, string | null, + string | null, number | null, string | null, string, string, number]; + const exists = ledgerRows.some((r) => r.webhook_id === webhook_id); + if (exists) return { meta: { changes: 0 } }; + ledgerRows.push({ id, webhook_id, event_type, polar_object_id, polar_customer_id, + polar_user_id, amount_cents, currency, status, meta_json, created_at }); + return { meta: { changes: 1 } }; + } + if (sql.includes('creator_earnings')) { + const [id, user_id, kind, amount_cents, platform_fee_cents, currency, + polar_order_id, description, created_at] = + bound as [string, string, string, number, number, string, + string | null, string | null, number]; + earningsRows.push({ id, user_id, kind, amount_cents, platform_fee_cents, + currency, polar_order_id: polar_order_id ?? null, + description: description ?? null, created_at }); + return { meta: { changes: 1 } }; + } + if (sql.includes('creator_payouts')) { + const [id, user_id, amount_cents, currency, polar_payout_id, status, paid_at, created_at] = + bound as [string, string, number, string, string | null, string, number | null, number]; + const existingIdx = payoutRows.findIndex((r) => r.polar_payout_id === polar_payout_id); + if (existingIdx >= 0) { + payoutRows[existingIdx].status = status; + if (paid_at !== null) payoutRows[existingIdx].paid_at = paid_at; + return { meta: { changes: 1 } }; + } + payoutRows.push({ id, user_id, amount_cents, currency, + polar_payout_id: polar_payout_id ?? null, + status, paid_at: paid_at ?? null, created_at }); + return { meta: { changes: 1 } }; + } + return { meta: { changes: 1 } }; + }, + }; + return stmt as unknown as D1PreparedStatement; + }, + } as unknown as D1Database; + + return { ledgerRows, earningsRows, payoutRows, binding: db }; +} + +describe('processEarningsEvent (via handlePolarWebhook)', () => { + it('credits creator_earnings when order.paid has valid metadata', async () => { + const app = buildApp(NOW); + const { earningsRows, binding } = makeFullFakeDB(); + const payload = JSON.stringify({ + type: 'order.paid', + data: { + id: 'ord_123', + amount: 1000, + currency: 'usd', + metadata: { kind: 'tip', creator_user_id: 'u_creator', video_id: 'vid_1' }, + }, + }); + const headers = await makeHeaders(payload); + const res = await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(res.status).toBe(200); + expect(earningsRows).toHaveLength(1); + expect(earningsRows[0].user_id).toBe('u_creator'); + expect(earningsRows[0].kind).toBe('tip'); + expect(earningsRows[0].amount_cents).toBe(1000); + expect(earningsRows[0].platform_fee_cents).toBe(100); // 10% of 1000 + expect(earningsRows[0].currency).toBe('usd'); + expect(earningsRows[0].polar_order_id).toBe('ord_123'); + expect(earningsRows[0].description).toContain('vid_1'); + }); + + it('sets membership description for membership orders', async () => { + const app = buildApp(NOW); + const { earningsRows, binding } = makeFullFakeDB(); + const payload = JSON.stringify({ + type: 'order.paid', + data: { + id: 'ord_mem', + amount: 500, + currency: 'usd', + metadata: { kind: 'membership', creator_user_id: 'u_creator' }, + }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(earningsRows).toHaveLength(1); + expect(earningsRows[0].kind).toBe('membership'); + expect(earningsRows[0].description).toBe('Membership payment'); + }); + + it('includes tip message in description when provided', async () => { + const app = buildApp(NOW); + const { earningsRows, binding } = makeFullFakeDB(); + const payload = JSON.stringify({ + type: 'order.paid', + data: { + id: 'ord_msg', + amount: 200, + currency: 'usd', + metadata: { kind: 'tip', creator_user_id: 'u_creator', video_id: 'vid_2', message: 'Great work!' }, + }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(earningsRows[0].description).toContain('Great work!'); + }); + + it('skips creator_earnings when order.paid has no creator_user_id', async () => { + const app = buildApp(NOW); + const { earningsRows, binding } = makeFullFakeDB(); + const payload = JSON.stringify({ + type: 'order.paid', + data: { id: 'ord_123', amount: 1000, currency: 'usd' }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(earningsRows).toHaveLength(0); + }); + + it('inserts creator_payouts as pending for payout.created', async () => { + const app = buildApp(NOW); + const { payoutRows, binding } = makeFullFakeDB({ userByPolarAccountId: { id: 'u_creator' } }); + const payload = JSON.stringify({ + type: 'payout.created', + data: { id: 'po_abc', amount: 8000, currency: 'usd', account_id: 'acct_123' }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(payoutRows).toHaveLength(1); + expect(payoutRows[0].user_id).toBe('u_creator'); + expect(payoutRows[0].status).toBe('pending'); + expect(payoutRows[0].polar_payout_id).toBe('po_abc'); + expect(payoutRows[0].amount_cents).toBe(8000); + expect(payoutRows[0].paid_at).toBeNull(); + }); + + it('records paid_at when payout.paid is received', async () => { + const app = buildApp(NOW); + const { payoutRows, binding } = makeFullFakeDB({ userByPolarAccountId: { id: 'u_creator' } }); + const payload = JSON.stringify({ + type: 'payout.paid', + data: { id: 'po_xyz', amount: 5000, currency: 'usd', account_id: 'acct_123' }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(payoutRows[0].status).toBe('paid'); + expect(payoutRows[0].paid_at).not.toBeNull(); + }); + + it('advances payout from pending to paid on payout.paid for same payout id', async () => { + const app = buildApp(NOW); + const { payoutRows, binding } = makeFullFakeDB({ userByPolarAccountId: { id: 'u_creator' } }); + + const created = JSON.stringify({ + type: 'payout.created', + data: { id: 'po_seq', amount: 3000, currency: 'usd', account_id: 'acct_123' }, + }); + const paid = JSON.stringify({ + type: 'payout.paid', + data: { id: 'po_seq', amount: 3000, currency: 'usd', account_id: 'acct_123' }, + }); + + const h1 = await makeHeaders(created, NOW, 'wh_1'); + const h2 = await makeHeaders(paid, NOW, 'wh_2'); + const env = { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }; + + await app.request('/api/webhooks/polar', { method: 'POST', headers: h1, body: created }, env); + await app.request('/api/webhooks/polar', { method: 'POST', headers: h2, body: paid }, env); + + // Only one payout row, status advanced to paid. + expect(payoutRows).toHaveLength(1); + expect(payoutRows[0].status).toBe('paid'); + expect(payoutRows[0].paid_at).not.toBeNull(); + }); + + it('skips creator_payouts when no user matches the polar account_id', async () => { + const app = buildApp(NOW); + const { payoutRows, binding } = makeFullFakeDB({ userByPolarAccountId: null }); + const payload = JSON.stringify({ + type: 'payout.created', + data: { id: 'po_abc', amount: 8000, currency: 'usd', account_id: 'acct_unknown' }, + }); + const headers = await makeHeaders(payload); + await app.request( + '/api/webhooks/polar', + { method: 'POST', headers, body: payload }, + { DB: binding, POLAR_WEBHOOK_SECRET: SECRET }, + ); + expect(payoutRows).toHaveLength(0); + }); +});