diff --git a/.env.example b/.env.example index d5bda514..01563200 100644 --- a/.env.example +++ b/.env.example @@ -86,3 +86,9 @@ REFERRAL_RATE_LIMIT_MAX_ATTEMPTS=10 REFERRAL_ENABLE_BOT_DETECTION=true # Enable VPN/Proxy detection (requires external service) REFERRAL_ENABLE_VPN_DETECTION=false + +# Stripe / Payments +# Secret key for the Stripe API (use a test-mode key, sk_test_..., in development) +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +# Signing secret used to verify webhook payloads (from the Stripe CLI or Dashboard webhook config) +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret diff --git a/.env.production.example b/.env.production.example index ed15326c..d054d461 100644 --- a/.env.production.example +++ b/.env.production.example @@ -53,3 +53,7 @@ HSTS_MAX_AGE=31536000 # Session Configuration SESSION_SECRET=REPLACE_WITH_STRONG_RANDOM_SECRET_MINIMUM_32_CHARACTERS + +# Stripe / Payments - use live-mode keys from the Stripe Dashboard +STRIPE_SECRET_KEY=sk_live_YOUR_STRIPE_SECRET_KEY +STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SIGNING_SECRET diff --git a/package-lock.json b/package-lock.json index 24fb7d23..25132e1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "rxjs": "^7.8.1", "socket.io": "^4.8.3", "speakeasy": "2.0.0", + "stripe": "^17.4.0", "swagger-ui-express": "^5.0.1", "toidentifier": "^1.0.1", "typeorm": "^0.3.19", @@ -2143,7 +2144,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, "optional": true }, "node_modules/@grpc/grpc-js": { @@ -3828,7 +3828,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "dev": true, "optional": true, "dependencies": { "@gar/promisify": "^1.0.1", @@ -3840,7 +3839,6 @@ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, "optional": true, "dependencies": { "mkdirp": "^1.0.4", @@ -3854,7 +3852,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -3866,7 +3863,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -3887,7 +3883,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -3900,7 +3895,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "optional": true, "bin": { "mkdirp": "bin/cmd.js" @@ -3914,7 +3908,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -6852,7 +6845,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "dev": true, "optional": true, "engines": { "node": ">= 6" @@ -7785,7 +7777,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true, "optional": true }, "node_modules/accepts": { @@ -7864,7 +7855,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, "optional": true, "dependencies": { "humanize-ms": "^1.2.1" @@ -7877,7 +7867,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "optional": true, "dependencies": { "clean-stack": "^2.0.0", @@ -8089,7 +8078,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, "optional": true }, "node_modules/are-we-there-yet": { @@ -8097,7 +8085,6 @@ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", "deprecated": "This package is no longer supported.", - "dev": true, "optional": true, "dependencies": { "delegates": "^1.0.0", @@ -8803,7 +8790,6 @@ "version": "15.3.0", "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "dev": true, "optional": true, "dependencies": { "@npmcli/fs": "^1.0.0", @@ -8833,7 +8819,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -8845,7 +8830,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -8866,7 +8850,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -8879,7 +8862,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -8892,7 +8874,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -8905,7 +8886,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, "optional": true, "bin": { "mkdirp": "bin/cmd.js" @@ -8919,7 +8899,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -8935,7 +8914,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/call-bind": { @@ -9162,7 +9140,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "optional": true, "engines": { "node": ">=6" @@ -9385,7 +9362,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, "optional": true, "bin": { "color-support": "bin.js" @@ -9462,7 +9438,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -9490,7 +9466,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, "optional": true }, "node_modules/content-disposition": { @@ -9839,7 +9814,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, "optional": true }, "node_modules/denque": { @@ -10192,7 +10166,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6" } @@ -10214,7 +10188,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, "optional": true }, "node_modules/error": { @@ -11498,7 +11471,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -11530,7 +11503,6 @@ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", "deprecated": "This package is no longer supported.", - "dev": true, "optional": true, "dependencies": { "aproba": "^1.0.3 || ^2.0.0", @@ -11550,7 +11522,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "optional": true }, "node_modules/gaxios": { @@ -11862,7 +11833,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/graphemer": { @@ -11966,7 +11937,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, "optional": true }, "node_modules/hasown": { @@ -12038,7 +12008,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, "optional": true }, "node_modules/http-errors": { @@ -12057,7 +12026,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "dev": true, "optional": true, "dependencies": { "@tootallnate/once": "1", @@ -12072,7 +12040,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -12108,7 +12075,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, "optional": true, "dependencies": { "ms": "^2.0.0" @@ -12232,7 +12198,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12242,7 +12208,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "optional": true, "engines": { "node": ">=8" @@ -12252,7 +12217,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, "optional": true }, "node_modules/inflight": { @@ -12260,7 +12224,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -12487,7 +12451,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, "optional": true }, "node_modules/is-number": { @@ -14210,7 +14173,6 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "dev": true, "optional": true, "dependencies": { "agentkeepalive": "^4.1.3", @@ -14238,7 +14200,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -14251,7 +14212,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "optional": true, "dependencies": { "agent-base": "6", @@ -14265,7 +14225,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14278,7 +14237,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14291,7 +14249,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/makeerror": { @@ -14523,7 +14480,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14536,7 +14492,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14549,14 +14504,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/minipass-fetch": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.0", @@ -14574,7 +14527,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14587,14 +14539,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/minipass-flush": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14607,7 +14557,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14620,14 +14569,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14640,7 +14587,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14653,14 +14599,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.0.0" @@ -14673,7 +14617,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -14686,7 +14629,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/minizlib": { @@ -14894,7 +14836,6 @@ "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "dev": true, "optional": true, "dependencies": { "env-paths": "^2.2.0", @@ -14945,7 +14886,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "optional": true, "dependencies": { "balanced-match": "^1.0.0", @@ -14957,7 +14897,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, "optional": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -14978,7 +14917,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "optional": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -14992,7 +14930,6 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, "optional": true, "dependencies": { "glob": "^7.1.3" @@ -15106,7 +15043,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "dev": true, "optional": true, "dependencies": { "abbrev": "1" @@ -15146,7 +15082,6 @@ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", "deprecated": "This package is no longer supported.", - "dev": true, "optional": true, "dependencies": { "are-we-there-yet": "^3.0.0", @@ -15352,7 +15287,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -15480,7 +15414,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -16019,14 +15953,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, "optional": true }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, "optional": true, "dependencies": { "err-code": "^2.0.2", @@ -16588,7 +16520,6 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, "optional": true, "engines": { "node": ">= 4" @@ -17160,7 +17091,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, "optional": true, "engines": { "node": ">= 6.0.0", @@ -17249,7 +17179,6 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, "optional": true, "dependencies": { "ip-address": "^10.0.1", @@ -17264,7 +17193,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "dev": true, "optional": true, "dependencies": { "agent-base": "^6.0.2", @@ -17279,7 +17207,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "optional": true, "dependencies": { "debug": "4" @@ -17400,7 +17327,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "dev": true, "optional": true, "dependencies": { "minipass": "^3.1.1" @@ -17413,7 +17339,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, "optional": true, "dependencies": { "yallist": "^4.0.0" @@ -17426,7 +17351,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "optional": true }, "node_modules/stack-utils": { @@ -17600,6 +17524,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/strnum": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", @@ -18766,7 +18703,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "dev": true, "optional": true, "dependencies": { "unique-slug": "^2.0.0" @@ -18776,7 +18712,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "dev": true, "optional": true, "dependencies": { "imurmurhash": "^0.1.4" @@ -19182,7 +19117,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" diff --git a/package.json b/package.json index 55423f00..350e255c 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "rxjs": "^7.8.1", "socket.io": "^4.8.3", "speakeasy": "2.0.0", + "stripe": "^17.4.0", "swagger-ui-express": "^5.0.1", "toidentifier": "^1.0.1", "typeorm": "^0.3.19", diff --git a/src/app.module.ts b/src/app.module.ts index 020be8a8..370dedca 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { AnalyticsModule } from "./analytics/analytics.module"; import { RateLimitModule } from "./quota/rate-limit.module"; import { NotificationsModule } from "./notifications/notifications.module"; import { MessagingModule } from "./messaging/messaging.module"; +import { PaymentsModule } from "./payments/payments.module"; // Auth entities import { Conversation } from "./messaging/entities/conversation.entity"; @@ -73,6 +74,12 @@ import { Notification } from "./notifications/entities/notification.entity"; import { NotificationDeliveryLog } from "./notifications/entities/notification-delivery-log.entity"; import { NotificationPreference } from "./notifications/entities/notification-preference.entity"; +// Payments entities +import { PaymentCustomer } from "./payments/entities/payment-customer.entity"; +import { Subscription } from "./payments/entities/subscription.entity"; +import { Transaction } from "./payments/entities/transaction.entity"; +import { WebhookEvent } from "./payments/entities/webhook-event.entity"; + // Guards import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard"; import { RolesGuard } from "./common/guard/roles.guard"; @@ -137,6 +144,11 @@ import { QuotaGuard } from "./common/guard/quota.guard"; Conversation, Message, UserPresence, + // Payments module entities + PaymentCustomer, + Subscription, + Transaction, + WebhookEvent, ], synchronize: !isProduction, logging: isProduction ? ["error"] : ["error", "warn", "schema"], @@ -176,6 +188,7 @@ import { QuotaGuard } from "./common/guard/quota.guard"; RateLimitModule, NotificationsModule, MessagingModule, + PaymentsModule, ], controllers: [AppController], diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index e3a53bb2..f174b16b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -104,6 +104,15 @@ export class EnvironmentVariables { @IsString() EMAIL_FROM: string = '"StellAIverse" '; + + // Stripe / Payments + @IsOptional() + @IsString() + STRIPE_SECRET_KEY?: string; + + @IsOptional() + @IsString() + STRIPE_WEBHOOK_SECRET?: string; } export function validateEnv(config: Record): EnvironmentVariables { diff --git a/src/main.ts b/src/main.ts index b6c86641..7105bf20 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,9 @@ async function bootstrap() { } // Create app with appropriate logging - const app = await NestFactory.create(AppModule); + // rawBody is required so the payments webhook controller can verify + // Stripe signatures against the exact bytes Stripe signed + const app = await NestFactory.create(AppModule, { rawBody: true }); const configService = app.get(ConfigService); const nodeEnv = configService.get("NODE_ENV"); const isProduction = nodeEnv === "production"; diff --git a/src/payments/README.md b/src/payments/README.md new file mode 100644 index 00000000..609f4a17 --- /dev/null +++ b/src/payments/README.md @@ -0,0 +1,75 @@ +# Payments Module + +Stripe-backed billing: customer management, subscriptions, and webhook-driven +transaction/invoice tracking. + +## Architecture + +``` +src/payments/ +├── entities/ +│ ├── payment-customer.entity.ts # user <-> Stripe customer mapping +│ ├── subscription.entity.ts # persisted subscription state +│ ├── transaction.entity.ts # charges & invoices +│ └── webhook-event.entity.ts # processed webhook ids (idempotency) +├── dto/ +├── events/ +│ └── payment-events.ts # EventEmitter2 event names/payloads +├── stripe.service.ts # thin wrapper around the Stripe SDK +├── payments.service.ts # customer/subscription/billing logic +├── payments-webhook.service.ts # idempotent webhook event processing +├── payments.controller.ts # authenticated billing endpoints +├── payments-webhook.controller.ts # POST /payments/webhook +└── payments.module.ts +``` + +## API Endpoints + +All endpoints below (except the webhook) require a valid JWT and act on the +authenticated user. + +- `POST /payments/customer` - create/update the caller's Stripe customer +- `POST /payments/subscriptions` - start a subscription for a given price +- `GET /payments/subscriptions` - list the caller's subscriptions +- `PATCH /payments/subscriptions/:id` - change a subscription's plan +- `DELETE /payments/subscriptions/:id` - cancel a subscription +- `GET /payments/invoices` - list Stripe invoices for the caller +- `GET /payments/transactions` - billing history persisted from webhooks +- `POST /payments/webhook` - Stripe webhook receiver (signature-verified, no auth) + +## Configuration + +```env +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +Both variables are read in `src/config/env.validation.ts`. `STRIPE_SECRET_KEY` +should be a **test-mode** key in development and a **live-mode** key in +production; `STRIPE_WEBHOOK_SECRET` is the signing secret for the webhook +endpoint you configure below. + +## Testing with the Stripe CLI + +1. Install the [Stripe CLI](https://stripe.com/docs/stripe-cli) and run + `stripe login`. +2. Forward events to your local server and copy the printed webhook signing + secret into `STRIPE_WEBHOOK_SECRET`: + + ```bash + stripe listen --forward-to localhost:3000/api/v1/payments/webhook + ``` + +3. Trigger events to exercise the handlers in `payments-webhook.service.ts`: + + ```bash + stripe trigger customer.subscription.updated + stripe trigger invoice.payment_succeeded + stripe trigger invoice.payment_failed + stripe trigger charge.succeeded + stripe trigger charge.failed + ``` + +Each event is recorded in `payment_webhook_events` by its Stripe event id +before being acted on, so re-delivered events (Stripe retries on a non-2xx +response) are detected and skipped rather than double-processed. diff --git a/src/payments/dto/cancel-subscription.dto.ts b/src/payments/dto/cancel-subscription.dto.ts new file mode 100644 index 00000000..a3e77342 --- /dev/null +++ b/src/payments/dto/cancel-subscription.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from "class-validator"; + +export class CancelSubscriptionDto { + @IsOptional() + @IsBoolean() + atPeriodEnd?: boolean = true; +} diff --git a/src/payments/dto/create-customer.dto.ts b/src/payments/dto/create-customer.dto.ts new file mode 100644 index 00000000..3577a10f --- /dev/null +++ b/src/payments/dto/create-customer.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from "class-validator"; + +export class CreateCustomerDto { + @IsOptional() + @IsString() + paymentMethodId?: string; +} diff --git a/src/payments/dto/create-subscription.dto.ts b/src/payments/dto/create-subscription.dto.ts new file mode 100644 index 00000000..4302aaf1 --- /dev/null +++ b/src/payments/dto/create-subscription.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class CreateSubscriptionDto { + @IsString() + @IsNotEmpty() + priceId: string; + + @IsOptional() + @IsString() + paymentMethodId?: string; +} diff --git a/src/payments/dto/update-subscription.dto.ts b/src/payments/dto/update-subscription.dto.ts new file mode 100644 index 00000000..0e99c4fe --- /dev/null +++ b/src/payments/dto/update-subscription.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class UpdateSubscriptionDto { + @IsString() + @IsNotEmpty() + priceId: string; +} diff --git a/src/payments/entities/payment-customer.entity.ts b/src/payments/entities/payment-customer.entity.ts new file mode 100644 index 00000000..da64acbf --- /dev/null +++ b/src/payments/entities/payment-customer.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +@Entity("payment_customers") +export class PaymentCustomer { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ unique: true }) + @Index() + userId: string; + + @Column({ unique: true }) + @Index() + stripeCustomerId: string; + + @Column({ nullable: true }) + defaultPaymentMethodId?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/payments/entities/subscription.entity.ts b/src/payments/entities/subscription.entity.ts new file mode 100644 index 00000000..60a00be9 --- /dev/null +++ b/src/payments/entities/subscription.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum SubscriptionStatus { + INCOMPLETE = "incomplete", + TRIALING = "trialing", + ACTIVE = "active", + PAST_DUE = "past_due", + CANCELED = "canceled", + UNPAID = "unpaid", +} + +@Entity("subscriptions") +export class Subscription { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + @Index() + userId: string; + + @Column({ unique: true }) + @Index() + stripeSubscriptionId: string; + + @Column() + stripeCustomerId: string; + + @Column() + priceId: string; + + @Column({ type: "enum", enum: SubscriptionStatus }) + status: SubscriptionStatus; + + @Column({ type: "timestamp" }) + currentPeriodStart: Date; + + @Column({ type: "timestamp" }) + currentPeriodEnd: Date; + + @Column({ default: false }) + cancelAtPeriodEnd: boolean; + + @Column({ type: "timestamp", nullable: true }) + canceledAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/payments/entities/transaction.entity.ts b/src/payments/entities/transaction.entity.ts new file mode 100644 index 00000000..f414f3e0 --- /dev/null +++ b/src/payments/entities/transaction.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +export enum TransactionType { + CHARGE = "charge", + INVOICE = "invoice", +} + +export enum TransactionStatus { + SUCCEEDED = "succeeded", + FAILED = "failed", + PENDING = "pending", + REFUNDED = "refunded", +} + +@Entity("payment_transactions") +export class Transaction { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + @Index() + userId: string; + + @Column({ nullable: true }) + subscriptionId?: string; + + @Column({ unique: true }) + @Index() + stripeObjectId: string; + + @Column({ type: "enum", enum: TransactionType }) + type: TransactionType; + + @Column({ type: "enum", enum: TransactionStatus }) + status: TransactionStatus; + + @Column({ type: "bigint" }) + amount: number; + + @Column() + currency: string; + + @Column({ nullable: true }) + invoiceUrl?: string; + + @Column({ nullable: true }) + failureReason?: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/payments/entities/webhook-event.entity.ts b/src/payments/entities/webhook-event.entity.ts new file mode 100644 index 00000000..7a4a852a --- /dev/null +++ b/src/payments/entities/webhook-event.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +@Entity("payment_webhook_events") +export class WebhookEvent { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ unique: true }) + @Index() + stripeEventId: string; + + @Column() + type: string; + + @CreateDateColumn() + processedAt: Date; +} diff --git a/src/payments/events/payment-events.ts b/src/payments/events/payment-events.ts new file mode 100644 index 00000000..2140a70e --- /dev/null +++ b/src/payments/events/payment-events.ts @@ -0,0 +1,16 @@ +export const PAYMENT_SUBSCRIPTION_UPDATED = "payment.subscription.updated"; +export const PAYMENT_CHARGE_SUCCEEDED = "payment.charge.succeeded"; +export const PAYMENT_CHARGE_FAILED = "payment.charge.failed"; + +export interface PaymentSubscriptionEventPayload { + userId: string; + subscriptionId: string; + status: string; +} + +export interface PaymentChargeEventPayload { + userId: string; + transactionId: string; + amount: number; + currency: string; +} diff --git a/src/payments/payments-webhook.controller.spec.ts b/src/payments/payments-webhook.controller.spec.ts new file mode 100644 index 00000000..4b42d187 --- /dev/null +++ b/src/payments/payments-webhook.controller.spec.ts @@ -0,0 +1,61 @@ +import { BadRequestException } from "@nestjs/common"; +import { PaymentsWebhookController } from "./payments-webhook.controller"; +import { StripeService } from "./stripe.service"; +import { PaymentsWebhookService } from "./payments-webhook.service"; + +describe("PaymentsWebhookController", () => { + let controller: PaymentsWebhookController; + const mockStripeService = { + constructEvent: jest.fn(), + }; + const mockWebhookService = { + processEvent: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + controller = new PaymentsWebhookController( + mockStripeService as unknown as StripeService, + mockWebhookService as unknown as PaymentsWebhookService, + ); + }); + + it("rejects requests missing the Stripe signature header", async () => { + const req = { rawBody: Buffer.from("{}") } as any; + + await expect( + controller.handleWebhook(req, undefined as any), + ).rejects.toThrow(BadRequestException); + }); + + it("rejects requests with no raw body", async () => { + const req = { rawBody: undefined } as any; + + await expect(controller.handleWebhook(req, "sig_123")).rejects.toThrow( + BadRequestException, + ); + }); + + it("rejects a request with an invalid signature", async () => { + const req = { rawBody: Buffer.from("{}") } as any; + mockStripeService.constructEvent.mockImplementation(() => { + throw new Error("invalid signature"); + }); + + await expect(controller.handleWebhook(req, "bad_sig")).rejects.toThrow( + BadRequestException, + ); + }); + + it("delegates a verified event to the webhook service", async () => { + const req = { rawBody: Buffer.from("{}") } as any; + const event = { id: "evt_1", type: "invoice.payment_succeeded" }; + mockStripeService.constructEvent.mockReturnValue(event); + mockWebhookService.processEvent.mockResolvedValue({ handled: true }); + + const result = await controller.handleWebhook(req, "sig_123"); + + expect(mockWebhookService.processEvent).toHaveBeenCalledWith(event); + expect(result).toEqual({ handled: true }); + }); +}); diff --git a/src/payments/payments-webhook.controller.ts b/src/payments/payments-webhook.controller.ts new file mode 100644 index 00000000..6ece3e72 --- /dev/null +++ b/src/payments/payments-webhook.controller.ts @@ -0,0 +1,44 @@ +import { + BadRequestException, + Controller, + Headers, + HttpCode, + Post, + RawBodyRequest, + Req, +} from "@nestjs/common"; +import { ApiExcludeController } from "@nestjs/swagger"; +import { Request } from "express"; +import { StripeService } from "./stripe.service"; +import { PaymentsWebhookService } from "./payments-webhook.service"; + +@ApiExcludeController() +@Controller("payments/webhook") +export class PaymentsWebhookController { + constructor( + private readonly stripeService: StripeService, + private readonly webhookService: PaymentsWebhookService, + ) {} + + @Post() + @HttpCode(200) + async handleWebhook( + @Req() req: RawBodyRequest, + @Headers("stripe-signature") signature: string, + ) { + if (!signature || !req.rawBody) { + throw new BadRequestException("Missing Stripe signature or payload"); + } + + let event; + try { + event = this.stripeService.constructEvent(req.rawBody, signature); + } catch (error) { + throw new BadRequestException( + `Webhook signature verification failed: ${error.message}`, + ); + } + + return this.webhookService.processEvent(event); + } +} diff --git a/src/payments/payments-webhook.service.spec.ts b/src/payments/payments-webhook.service.spec.ts new file mode 100644 index 00000000..494f49a3 --- /dev/null +++ b/src/payments/payments-webhook.service.spec.ts @@ -0,0 +1,185 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { PaymentsWebhookService } from "./payments-webhook.service"; +import { PaymentsService } from "./payments.service"; +import { WebhookEvent } from "./entities/webhook-event.entity"; +import { + TransactionStatus, + TransactionType, +} from "./entities/transaction.entity"; + +const mockWebhookEventRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + +const mockPaymentsService = { + upsertSubscriptionFromStripe: jest.fn(), + recordTransaction: jest.fn(), + findUserIdByStripeCustomerId: jest.fn(), +}; + +const mockEmitter = { + emit: jest.fn(), +}; + +describe("PaymentsWebhookService", () => { + let service: PaymentsWebhookService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsWebhookService, + { + provide: getRepositoryToken(WebhookEvent), + useValue: mockWebhookEventRepo, + }, + { provide: PaymentsService, useValue: mockPaymentsService }, + { provide: EventEmitter2, useValue: mockEmitter }, + ], + }).compile(); + + service = module.get(PaymentsWebhookService); + + jest.clearAllMocks(); + mockWebhookEventRepo.create.mockImplementation((p) => ({ ...p })); + mockWebhookEventRepo.save.mockImplementation((p) => Promise.resolve(p)); + }); + + it("skips events that were already processed", async () => { + mockWebhookEventRepo.findOne.mockResolvedValue({ stripeEventId: "evt_1" }); + + const result = await service.processEvent({ + id: "evt_1", + type: "invoice.payment_succeeded", + data: { object: {} }, + } as any); + + expect(result.handled).toBe(false); + expect(mockPaymentsService.recordTransaction).not.toHaveBeenCalled(); + }); + + it("records a successful invoice payment and emits an event", async () => { + mockWebhookEventRepo.findOne.mockResolvedValue(null); + mockPaymentsService.findUserIdByStripeCustomerId.mockResolvedValue( + "user-1", + ); + mockPaymentsService.recordTransaction.mockResolvedValue({ + id: "txn-1", + amount: 1999, + currency: "usd", + }); + + const result = await service.processEvent({ + id: "evt_2", + type: "invoice.payment_succeeded", + data: { + object: { + id: "in_123", + customer: "cus_123", + subscription: "sub_123", + amount_paid: 1999, + currency: "usd", + hosted_invoice_url: "https://stripe.test/invoice", + }, + }, + } as any); + + expect(result.handled).toBe(true); + expect(mockPaymentsService.recordTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + status: TransactionStatus.SUCCEEDED, + type: TransactionType.INVOICE, + }), + ); + expect(mockEmitter.emit).toHaveBeenCalled(); + expect(mockWebhookEventRepo.save).toHaveBeenCalled(); + }); + + it("records a failed invoice payment without throwing", async () => { + mockWebhookEventRepo.findOne.mockResolvedValue(null); + mockPaymentsService.findUserIdByStripeCustomerId.mockResolvedValue( + "user-1", + ); + mockPaymentsService.recordTransaction.mockResolvedValue({ + id: "txn-2", + amount: 1999, + currency: "usd", + }); + + const result = await service.processEvent({ + id: "evt_3", + type: "invoice.payment_failed", + data: { + object: { + id: "in_456", + customer: "cus_123", + subscription: "sub_123", + amount_due: 1999, + currency: "usd", + }, + }, + } as any); + + expect(result.handled).toBe(true); + expect(mockPaymentsService.recordTransaction).toHaveBeenCalledWith( + expect.objectContaining({ status: TransactionStatus.FAILED }), + ); + }); + + it("updates subscription state on customer.subscription.updated", async () => { + mockWebhookEventRepo.findOne.mockResolvedValue(null); + mockPaymentsService.findUserIdByStripeCustomerId.mockResolvedValue( + "user-1", + ); + mockPaymentsService.upsertSubscriptionFromStripe.mockResolvedValue({ + id: "sub-record-1", + status: "active", + }); + + const result = await service.processEvent({ + id: "evt_4", + type: "customer.subscription.updated", + data: { + object: { + id: "sub_123", + customer: "cus_123", + metadata: {}, + }, + }, + } as any); + + expect(result.handled).toBe(true); + expect( + mockPaymentsService.upsertSubscriptionFromStripe, + ).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ id: "sub_123" }), + ); + expect(mockEmitter.emit).toHaveBeenCalled(); + }); + + it("does nothing for an unknown customer", async () => { + mockWebhookEventRepo.findOne.mockResolvedValue(null); + mockPaymentsService.findUserIdByStripeCustomerId.mockResolvedValue(null); + + const result = await service.processEvent({ + id: "evt_5", + type: "charge.succeeded", + data: { + object: { + id: "ch_123", + customer: "cus_unknown", + amount: 500, + currency: "usd", + }, + }, + } as any); + + expect(result.handled).toBe(true); + expect(mockPaymentsService.recordTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/payments/payments-webhook.service.ts b/src/payments/payments-webhook.service.ts new file mode 100644 index 00000000..95bd458d --- /dev/null +++ b/src/payments/payments-webhook.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import Stripe from "stripe"; +import { WebhookEvent } from "./entities/webhook-event.entity"; +import { + TransactionStatus, + TransactionType, +} from "./entities/transaction.entity"; +import { PaymentsService } from "./payments.service"; +import { + PAYMENT_CHARGE_FAILED, + PAYMENT_CHARGE_SUCCEEDED, + PAYMENT_SUBSCRIPTION_UPDATED, +} from "./events/payment-events"; + +@Injectable() +export class PaymentsWebhookService { + private readonly logger = new Logger(PaymentsWebhookService.name); + + constructor( + @InjectRepository(WebhookEvent) + private readonly webhookEventRepo: Repository, + private readonly paymentsService: PaymentsService, + private readonly emitter: EventEmitter2, + ) {} + + async processEvent(event: Stripe.Event): Promise<{ handled: boolean }> { + const alreadyProcessed = await this.webhookEventRepo.findOne({ + where: { stripeEventId: event.id }, + }); + if (alreadyProcessed) { + this.logger.log(`Skipping already processed webhook event ${event.id}`); + return { handled: false }; + } + + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": + await this.handleSubscriptionUpdated( + event.data.object as Stripe.Subscription, + ); + break; + case "invoice.payment_succeeded": + await this.handleInvoicePaid(event.data.object as Stripe.Invoice); + break; + case "invoice.payment_failed": + await this.handleInvoiceFailed(event.data.object as Stripe.Invoice); + break; + case "charge.succeeded": + await this.handleChargeSucceeded(event.data.object as Stripe.Charge); + break; + case "charge.failed": + await this.handleChargeFailed(event.data.object as Stripe.Charge); + break; + default: + this.logger.log(`Unhandled webhook event type ${event.type}`); + } + + await this.webhookEventRepo.save( + this.webhookEventRepo.create({ + stripeEventId: event.id, + type: event.type, + }), + ); + + return { handled: true }; + } + + private async handleSubscriptionUpdated( + stripeSubscription: Stripe.Subscription, + ): Promise { + const userId = + (stripeSubscription.metadata?.userId as string | undefined) ?? + (await this.paymentsService.findUserIdByStripeCustomerId( + stripeSubscription.customer as string, + )); + + if (!userId) { + this.logger.warn( + `Received subscription event for unknown customer ${stripeSubscription.customer}`, + ); + return; + } + + const subscription = + await this.paymentsService.upsertSubscriptionFromStripe( + userId, + stripeSubscription, + ); + + this.emitter.emit(PAYMENT_SUBSCRIPTION_UPDATED, { + userId, + subscriptionId: subscription.id, + status: subscription.status, + }); + } + + private async handleInvoicePaid(invoice: Stripe.Invoice): Promise { + const userId = await this.resolveUserIdForCustomer( + invoice.customer as string, + ); + if (!userId) { + return; + } + + const transaction = await this.paymentsService.recordTransaction({ + userId, + subscriptionId: + typeof invoice.subscription === "string" + ? invoice.subscription + : undefined, + stripeObjectId: invoice.id, + type: TransactionType.INVOICE, + status: TransactionStatus.SUCCEEDED, + amount: invoice.amount_paid, + currency: invoice.currency, + invoiceUrl: invoice.hosted_invoice_url ?? undefined, + }); + + this.emitter.emit(PAYMENT_CHARGE_SUCCEEDED, { + userId, + transactionId: transaction.id, + amount: transaction.amount, + currency: transaction.currency, + }); + } + + private async handleInvoiceFailed(invoice: Stripe.Invoice): Promise { + const userId = await this.resolveUserIdForCustomer( + invoice.customer as string, + ); + if (!userId) { + return; + } + + const transaction = await this.paymentsService.recordTransaction({ + userId, + subscriptionId: + typeof invoice.subscription === "string" + ? invoice.subscription + : undefined, + stripeObjectId: invoice.id, + type: TransactionType.INVOICE, + status: TransactionStatus.FAILED, + amount: invoice.amount_due, + currency: invoice.currency, + failureReason: + invoice.last_finalization_error?.message ?? "Payment failed", + }); + + this.emitter.emit(PAYMENT_CHARGE_FAILED, { + userId, + transactionId: transaction.id, + amount: transaction.amount, + currency: transaction.currency, + }); + } + + private async handleChargeSucceeded(charge: Stripe.Charge): Promise { + const userId = await this.resolveUserIdForCustomer( + charge.customer as string, + ); + if (!userId) { + return; + } + + await this.paymentsService.recordTransaction({ + userId, + stripeObjectId: charge.id, + type: TransactionType.CHARGE, + status: TransactionStatus.SUCCEEDED, + amount: charge.amount, + currency: charge.currency, + invoiceUrl: charge.receipt_url ?? undefined, + }); + } + + private async handleChargeFailed(charge: Stripe.Charge): Promise { + const userId = await this.resolveUserIdForCustomer( + charge.customer as string, + ); + if (!userId) { + return; + } + + await this.paymentsService.recordTransaction({ + userId, + stripeObjectId: charge.id, + type: TransactionType.CHARGE, + status: TransactionStatus.FAILED, + amount: charge.amount, + currency: charge.currency, + failureReason: charge.failure_message ?? "Charge failed", + }); + } + + private async resolveUserIdForCustomer( + stripeCustomerId: string | null, + ): Promise { + if (!stripeCustomerId) { + return null; + } + const userId = + await this.paymentsService.findUserIdByStripeCustomerId(stripeCustomerId); + if (!userId) { + this.logger.warn( + `Received webhook for unknown customer ${stripeCustomerId}`, + ); + } + return userId; + } +} diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts new file mode 100644 index 00000000..f90d3a83 --- /dev/null +++ b/src/payments/payments.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Patch, + UseGuards, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { AuthUser } from "../auth/strategies/interfaces/auth-strategy.interface"; +import { PaymentsService } from "./payments.service"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { CreateSubscriptionDto } from "./dto/create-subscription.dto"; +import { UpdateSubscriptionDto } from "./dto/update-subscription.dto"; +import { CancelSubscriptionDto } from "./dto/cancel-subscription.dto"; + +@ApiTags("payments") +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller("payments") +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post("customer") + @ApiOperation({ + summary: "Create or update the billing customer for the current user", + }) + createCustomer( + @CurrentUser() user: AuthUser, + @Body() dto: CreateCustomerDto, + ) { + return this.paymentsService.getOrCreateCustomer(user.id, dto); + } + + @Post("subscriptions") + @ApiOperation({ summary: "Create a subscription for the current user" }) + createSubscription( + @CurrentUser() user: AuthUser, + @Body() dto: CreateSubscriptionDto, + ) { + return this.paymentsService.createSubscription(user.id, dto); + } + + @Get("subscriptions") + @ApiOperation({ summary: "List subscriptions for the current user" }) + getSubscriptions(@CurrentUser() user: AuthUser) { + return this.paymentsService.getUserSubscriptions(user.id); + } + + @Patch("subscriptions/:id") + @ApiOperation({ summary: "Change the plan of an existing subscription" }) + updateSubscription( + @CurrentUser() user: AuthUser, + @Param("id") id: string, + @Body() dto: UpdateSubscriptionDto, + ) { + return this.paymentsService.updateSubscription(user.id, id, dto); + } + + @Delete("subscriptions/:id") + @ApiOperation({ summary: "Cancel a subscription" }) + cancelSubscription( + @CurrentUser() user: AuthUser, + @Param("id") id: string, + @Body() dto: CancelSubscriptionDto, + ) { + return this.paymentsService.cancelSubscription(user.id, id, dto); + } + + @Get("invoices") + @ApiOperation({ summary: "List invoices for the current user" }) + getInvoices(@CurrentUser() user: AuthUser) { + return this.paymentsService.getInvoices(user.id); + } + + @Get("transactions") + @ApiOperation({ summary: "Get billing history for the current user" }) + getBillingHistory(@CurrentUser() user: AuthUser) { + return this.paymentsService.getBillingHistory(user.id); + } +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts new file mode 100644 index 00000000..771d7c9a --- /dev/null +++ b/src/payments/payments.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ConfigModule } from "@nestjs/config"; +import { PaymentCustomer } from "./entities/payment-customer.entity"; +import { Subscription } from "./entities/subscription.entity"; +import { Transaction } from "./entities/transaction.entity"; +import { WebhookEvent } from "./entities/webhook-event.entity"; +import { StripeService } from "./stripe.service"; +import { PaymentsService } from "./payments.service"; +import { PaymentsWebhookService } from "./payments-webhook.service"; +import { PaymentsController } from "./payments.controller"; +import { PaymentsWebhookController } from "./payments-webhook.controller"; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([ + PaymentCustomer, + Subscription, + Transaction, + WebhookEvent, + ]), + ], + controllers: [PaymentsController, PaymentsWebhookController], + providers: [StripeService, PaymentsService, PaymentsWebhookService], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/src/payments/payments.service.spec.ts b/src/payments/payments.service.spec.ts new file mode 100644 index 00000000..5b6fa3fe --- /dev/null +++ b/src/payments/payments.service.spec.ts @@ -0,0 +1,223 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import { NotFoundException } from "@nestjs/common"; +import { PaymentsService } from "./payments.service"; +import { StripeService } from "./stripe.service"; +import { PaymentCustomer } from "./entities/payment-customer.entity"; +import { + Subscription, + SubscriptionStatus, +} from "./entities/subscription.entity"; +import { Transaction } from "./entities/transaction.entity"; + +const mockCustomerRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + +const mockSubscriptionRepo = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + +const mockTransactionRepo = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + +const mockStripeService = { + createCustomer: jest.fn(), + attachPaymentMethod: jest.fn(), + createSubscription: jest.fn(), + updateSubscription: jest.fn(), + cancelSubscription: jest.fn(), + listInvoices: jest.fn(), + instance: { + subscriptions: { + retrieve: jest.fn(), + }, + }, +}; + +function buildStripeSubscription(overrides: Record = {}) { + return { + id: "sub_123", + customer: "cus_123", + status: "active", + current_period_start: 1700000000, + current_period_end: 1702600000, + cancel_at_period_end: false, + canceled_at: null, + items: { data: [{ id: "si_1", price: { id: "price_basic" } }] }, + ...overrides, + }; +} + +describe("PaymentsService", () => { + let service: PaymentsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentsService, + { + provide: getRepositoryToken(PaymentCustomer), + useValue: mockCustomerRepo, + }, + { + provide: getRepositoryToken(Subscription), + useValue: mockSubscriptionRepo, + }, + { + provide: getRepositoryToken(Transaction), + useValue: mockTransactionRepo, + }, + { provide: StripeService, useValue: mockStripeService }, + ], + }).compile(); + + service = module.get(PaymentsService); + + jest.clearAllMocks(); + mockCustomerRepo.create.mockImplementation((p) => ({ ...p })); + mockCustomerRepo.save.mockImplementation((p) => + Promise.resolve({ id: "cust-1", ...p }), + ); + mockSubscriptionRepo.create.mockImplementation((p) => ({ ...p })); + mockSubscriptionRepo.save.mockImplementation((p) => + Promise.resolve({ id: "sub-record-1", ...p }), + ); + mockTransactionRepo.create.mockImplementation((p) => ({ ...p })); + mockTransactionRepo.save.mockImplementation((p) => + Promise.resolve({ id: "txn-1", ...p }), + ); + }); + + describe("getOrCreateCustomer", () => { + it("creates a new Stripe customer when none exists", async () => { + mockCustomerRepo.findOne.mockResolvedValue(null); + mockStripeService.createCustomer.mockResolvedValue({ id: "cus_123" }); + + const customer = await service.getOrCreateCustomer("user-1", {}); + + expect(mockStripeService.createCustomer).toHaveBeenCalled(); + expect(customer.stripeCustomerId).toBe("cus_123"); + expect(customer.userId).toBe("user-1"); + }); + + it("reuses an existing customer and attaches a new payment method", async () => { + mockCustomerRepo.findOne.mockResolvedValue({ + userId: "user-1", + stripeCustomerId: "cus_123", + }); + + const customer = await service.getOrCreateCustomer("user-1", { + paymentMethodId: "pm_new", + }); + + expect(mockStripeService.attachPaymentMethod).toHaveBeenCalledWith( + "pm_new", + "cus_123", + ); + expect(mockStripeService.createCustomer).not.toHaveBeenCalled(); + expect(customer.stripeCustomerId).toBe("cus_123"); + }); + }); + + describe("createSubscription", () => { + it("persists a subscription created via Stripe", async () => { + mockCustomerRepo.findOne.mockResolvedValue({ + userId: "user-1", + stripeCustomerId: "cus_123", + }); + mockStripeService.createSubscription.mockResolvedValue( + buildStripeSubscription(), + ); + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + const subscription = await service.createSubscription("user-1", { + priceId: "price_basic", + }); + + expect(subscription.status).toBe(SubscriptionStatus.ACTIVE); + expect(subscription.priceId).toBe("price_basic"); + expect(subscription.stripeSubscriptionId).toBe("sub_123"); + }); + }); + + describe("cancelSubscription", () => { + it("throws NotFoundException for a subscription the user does not own", async () => { + mockSubscriptionRepo.findOne.mockResolvedValue(null); + + await expect( + service.cancelSubscription("user-1", "unknown-id", {}), + ).rejects.toThrow(NotFoundException); + }); + + it("marks the subscription canceled at period end by default", async () => { + mockSubscriptionRepo.findOne.mockResolvedValue({ + id: "sub-record-1", + userId: "user-1", + stripeSubscriptionId: "sub_123", + }); + mockStripeService.cancelSubscription.mockResolvedValue( + buildStripeSubscription({ cancel_at_period_end: true }), + ); + + const subscription = await service.cancelSubscription( + "user-1", + "sub-record-1", + {}, + ); + + expect(mockStripeService.cancelSubscription).toHaveBeenCalledWith( + "sub_123", + true, + ); + expect(subscription.cancelAtPeriodEnd).toBe(true); + }); + }); + + describe("recordTransaction", () => { + it("creates a new transaction record", async () => { + mockTransactionRepo.findOne.mockResolvedValue(null); + + const transaction = await service.recordTransaction({ + userId: "user-1", + stripeObjectId: "in_123", + amount: 1999, + currency: "usd", + }); + + expect(transaction.stripeObjectId).toBe("in_123"); + expect(mockTransactionRepo.save).toHaveBeenCalled(); + }); + + it("updates an existing transaction instead of duplicating it", async () => { + const existing = { + id: "txn-existing", + userId: "user-1", + stripeObjectId: "in_123", + amount: 1999, + currency: "usd", + }; + mockTransactionRepo.findOne.mockResolvedValue(existing); + + await service.recordTransaction({ + stripeObjectId: "in_123", + amount: 1999, + currency: "usd", + failureReason: "card_declined", + }); + + expect(mockTransactionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ failureReason: "card_declined" }), + ); + }); + }); +}); diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts new file mode 100644 index 00000000..cca677ad --- /dev/null +++ b/src/payments/payments.service.ts @@ -0,0 +1,241 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import Stripe from "stripe"; +import { StripeService } from "./stripe.service"; +import { PaymentCustomer } from "./entities/payment-customer.entity"; +import { + Subscription, + SubscriptionStatus, +} from "./entities/subscription.entity"; +import { Transaction } from "./entities/transaction.entity"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { CreateSubscriptionDto } from "./dto/create-subscription.dto"; +import { UpdateSubscriptionDto } from "./dto/update-subscription.dto"; +import { CancelSubscriptionDto } from "./dto/cancel-subscription.dto"; + +const STRIPE_TO_INTERNAL_STATUS: Record = { + incomplete: SubscriptionStatus.INCOMPLETE, + incomplete_expired: SubscriptionStatus.INCOMPLETE, + trialing: SubscriptionStatus.TRIALING, + active: SubscriptionStatus.ACTIVE, + past_due: SubscriptionStatus.PAST_DUE, + canceled: SubscriptionStatus.CANCELED, + unpaid: SubscriptionStatus.UNPAID, + paused: SubscriptionStatus.UNPAID, +}; + +@Injectable() +export class PaymentsService { + constructor( + @InjectRepository(PaymentCustomer) + private readonly customerRepo: Repository, + @InjectRepository(Subscription) + private readonly subscriptionRepo: Repository, + @InjectRepository(Transaction) + private readonly transactionRepo: Repository, + private readonly stripeService: StripeService, + ) {} + + async getOrCreateCustomer( + userId: string, + dto: CreateCustomerDto = {}, + ): Promise { + const existing = await this.customerRepo.findOne({ where: { userId } }); + if (existing) { + if (dto.paymentMethodId) { + await this.stripeService.attachPaymentMethod( + dto.paymentMethodId, + existing.stripeCustomerId, + ); + existing.defaultPaymentMethodId = dto.paymentMethodId; + await this.customerRepo.save(existing); + } + return existing; + } + + const stripeCustomer = await this.stripeService.createCustomer({ + metadata: { userId }, + payment_method: dto.paymentMethodId, + invoice_settings: dto.paymentMethodId + ? { default_payment_method: dto.paymentMethodId } + : undefined, + }); + + const customer = this.customerRepo.create({ + userId, + stripeCustomerId: stripeCustomer.id, + defaultPaymentMethodId: dto.paymentMethodId, + }); + + return this.customerRepo.save(customer); + } + + async createSubscription( + userId: string, + dto: CreateSubscriptionDto, + ): Promise { + const customer = await this.getOrCreateCustomer(userId, { + paymentMethodId: dto.paymentMethodId, + }); + + const stripeSubscription = await this.stripeService.createSubscription({ + customer: customer.stripeCustomerId, + items: [{ price: dto.priceId }], + payment_behavior: "default_incomplete", + expand: ["latest_invoice.payment_intent"], + metadata: { userId }, + }); + + return this.upsertSubscriptionFromStripe(userId, stripeSubscription); + } + + async updateSubscription( + userId: string, + subscriptionId: string, + dto: UpdateSubscriptionDto, + ): Promise { + const subscription = await this.getUserSubscriptionOrThrow( + userId, + subscriptionId, + ); + + const current = await this.stripeService.instance.subscriptions.retrieve( + subscription.stripeSubscriptionId, + ); + const itemId = current.items.data[0]?.id; + if (!itemId) { + throw new BadRequestException("Subscription has no billing item"); + } + + const stripeSubscription = await this.stripeService.updateSubscription( + subscription.stripeSubscriptionId, + { + items: [{ id: itemId, price: dto.priceId }], + proration_behavior: "create_prorations", + }, + ); + + return this.upsertSubscriptionFromStripe(userId, stripeSubscription); + } + + async cancelSubscription( + userId: string, + subscriptionId: string, + dto: CancelSubscriptionDto, + ): Promise { + const subscription = await this.getUserSubscriptionOrThrow( + userId, + subscriptionId, + ); + + const stripeSubscription = await this.stripeService.cancelSubscription( + subscription.stripeSubscriptionId, + dto.atPeriodEnd ?? true, + ); + + return this.upsertSubscriptionFromStripe(userId, stripeSubscription); + } + + async getUserSubscriptions(userId: string): Promise { + return this.subscriptionRepo.find({ + where: { userId }, + order: { createdAt: "DESC" }, + }); + } + + async getBillingHistory(userId: string): Promise { + return this.transactionRepo.find({ + where: { userId }, + order: { createdAt: "DESC" }, + }); + } + + async getInvoices(userId: string): Promise { + const customer = await this.customerRepo.findOne({ where: { userId } }); + if (!customer) { + return []; + } + const invoices = await this.stripeService.listInvoices( + customer.stripeCustomerId, + ); + return invoices.data; + } + + private async getUserSubscriptionOrThrow( + userId: string, + subscriptionId: string, + ): Promise { + const subscription = await this.subscriptionRepo.findOne({ + where: { id: subscriptionId, userId }, + }); + if (!subscription) { + throw new NotFoundException("Subscription not found"); + } + return subscription; + } + + async upsertSubscriptionFromStripe( + userId: string, + stripeSubscription: Stripe.Subscription, + ): Promise { + let subscription = await this.subscriptionRepo.findOne({ + where: { stripeSubscriptionId: stripeSubscription.id }, + }); + + const status = + STRIPE_TO_INTERNAL_STATUS[stripeSubscription.status] ?? + SubscriptionStatus.INCOMPLETE; + const item = stripeSubscription.items.data[0]; + + const fields = { + userId, + stripeSubscriptionId: stripeSubscription.id, + stripeCustomerId: stripeSubscription.customer as string, + priceId: item?.price?.id ?? subscription?.priceId ?? "", + status, + currentPeriodStart: new Date( + stripeSubscription.current_period_start * 1000, + ), + currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000), + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, + canceledAt: stripeSubscription.canceled_at + ? new Date(stripeSubscription.canceled_at * 1000) + : undefined, + }; + + if (subscription) { + Object.assign(subscription, fields); + } else { + subscription = this.subscriptionRepo.create(fields); + } + + return this.subscriptionRepo.save(subscription); + } + + async recordTransaction( + transaction: Partial, + ): Promise { + const existing = await this.transactionRepo.findOne({ + where: { stripeObjectId: transaction.stripeObjectId }, + }); + if (existing) { + Object.assign(existing, transaction); + return this.transactionRepo.save(existing); + } + return this.transactionRepo.save(this.transactionRepo.create(transaction)); + } + + async findUserIdByStripeCustomerId( + stripeCustomerId: string, + ): Promise { + const customer = await this.customerRepo.findOne({ + where: { stripeCustomerId }, + }); + return customer?.userId ?? null; + } +} diff --git a/src/payments/stripe.service.ts b/src/payments/stripe.service.ts new file mode 100644 index 00000000..a96780ab --- /dev/null +++ b/src/payments/stripe.service.ts @@ -0,0 +1,79 @@ +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Stripe from "stripe"; + +@Injectable() +export class StripeService implements OnModuleInit { + private client: Stripe; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit() { + const secretKey = this.configService.get("STRIPE_SECRET_KEY"); + this.client = new Stripe(secretKey || "sk_test_placeholder", { + apiVersion: "2025-02-24.acacia", + }); + } + + get instance(): Stripe { + return this.client; + } + + createCustomer( + params: Stripe.CustomerCreateParams, + ): Promise { + return this.client.customers.create(params); + } + + attachPaymentMethod( + paymentMethodId: string, + customerId: string, + ): Promise { + return this.client.paymentMethods.attach(paymentMethodId, { + customer: customerId, + }); + } + + createSubscription( + params: Stripe.SubscriptionCreateParams, + ): Promise { + return this.client.subscriptions.create(params); + } + + updateSubscription( + subscriptionId: string, + params: Stripe.SubscriptionUpdateParams, + ): Promise { + return this.client.subscriptions.update(subscriptionId, params); + } + + cancelSubscription( + subscriptionId: string, + atPeriodEnd: boolean, + ): Promise { + if (atPeriodEnd) { + return this.client.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + } + return this.client.subscriptions.cancel(subscriptionId); + } + + listInvoices( + customerId: string, + limit = 20, + ): Promise> { + return this.client.invoices.list({ customer: customerId, limit }); + } + + constructEvent(payload: Buffer, signature: string): Stripe.Event { + const webhookSecret = this.configService.get( + "STRIPE_WEBHOOK_SECRET", + ); + return this.client.webhooks.constructEvent( + payload, + signature, + webhookSecret, + ); + } +}