From dd2c4ad91e234740e2465826942a983eeb52512f Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Wed, 25 Mar 2026 09:41:19 +0530 Subject: [PATCH 1/3] feat(provider): implement a comprehensive subscription lifecycle in stripe Implement a comprehensive subscription lifecycle within loopback4-billing to support automatedrecurring billing, including subscription creation, upgrades and downgrades, renewals,cancellations, and proration, ensuring consistency and scalability for SaaS monetization --- package-lock.json | 45 +- package.json | 4 +- .../unit/stripe-subscription.service.unit.ts | 643 ++++++++++++++++++ src/keys.ts | 11 +- src/providers/billing.provider.ts | 25 +- src/providers/sdk/stripe/adapter/index.ts | 1 + .../stripe/adapter/subscription.adapter.ts | 67 ++ src/providers/sdk/stripe/stripe.service.ts | 280 +++++++- src/providers/sdk/stripe/type/index.ts | 17 +- .../sdk/stripe/type/stripe-config.type.ts | 14 + src/types.ts | 168 +++++ 11 files changed, 1224 insertions(+), 51 deletions(-) create mode 100644 src/__tests__/unit/stripe-subscription.service.unit.ts create mode 100644 src/providers/sdk/stripe/adapter/subscription.adapter.ts create mode 100644 src/providers/sdk/stripe/type/stripe-config.type.ts diff --git a/package-lock.json b/package-lock.json index 2f3a2d2..9eec7b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1214,7 +1214,6 @@ "resolved": "https://registry.npmjs.org/@loopback/core/-/core-7.0.4.tgz", "integrity": "sha512-SjPTGa4T9DfQvRJ/drDfNpDjwKaOlpAMpTuaPBS83U6NjtLb6auOVIYJ3/nf+iZC58QAC8fZQnx45uWBgtQEUg==", "license": "MIT", - "peer": true, "dependencies": { "@loopback/context": "^8.0.4", "debug": "^4.4.1", @@ -1275,6 +1274,7 @@ "resolved": "https://registry.npmjs.org/@loopback/filter/-/filter-6.0.4.tgz", "integrity": "sha512-RjCdyIG9bKFbi4OWWOL1kH2c1vpF+o6jWVgh0J32h88rmQQpXE0qoDhilRK3Z880wRAizMv4V8UHB6hYLAIGhg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.8.1" }, @@ -1376,7 +1376,6 @@ "resolved": "https://registry.npmjs.org/@loopback/rest/-/rest-15.0.5.tgz", "integrity": "sha512-gOr7xJ5SvDruyt955+H1UswANETRE7d5lyfWFZ7ETVsqJ3Yl3bKyGAJ7gR/twKO2WWtr5pe6wavC48zkMI/1og==", "license": "MIT", - "peer": true, "dependencies": { "@loopback/express": "^8.0.4", "@loopback/http-server": "^7.0.4", @@ -1527,7 +1526,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2602,7 +2600,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz", "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2754,7 +2751,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2956,7 +2952,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3016,7 +3011,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3289,6 +3283,7 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", "license": "MIT", + "peer": true, "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", @@ -3314,6 +3309,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -3324,6 +3320,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -3337,7 +3334,8 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/body-parser": { "version": "2.2.0", @@ -3415,7 +3413,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3560,6 +3557,7 @@ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", "license": "MIT", + "peer": true, "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" @@ -3619,6 +3617,7 @@ "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", "license": "MIT", + "peer": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -3647,6 +3646,7 @@ "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", "license": "MIT", + "peer": true, "dependencies": { "camel-case": "^4.1.2", "capital-case": "^1.0.4", @@ -4044,6 +4044,7 @@ "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", "license": "MIT", + "peer": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -4239,7 +4240,6 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -4998,6 +4998,7 @@ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", "license": "MIT", + "peer": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -5392,7 +5393,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6928,6 +6928,7 @@ "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", "license": "MIT", + "peer": true, "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" @@ -7240,6 +7241,7 @@ "resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz", "integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" } @@ -8196,6 +8198,7 @@ "resolved": "https://registry.npmjs.org/loopback-connector/-/loopback-connector-6.2.11.tgz", "integrity": "sha512-4jcFe64x7KNXTqp/vcBnl1M8wzPaFsL6RzVcCcZTUzGUEDdifi5Gc9VXu9Qbb0OcTNaE49KyeGG/LIXxuzV48A==", "license": "MIT", + "peer": true, "dependencies": { "async": "^3.2.6", "bluebird": "^3.7.2", @@ -8213,6 +8216,7 @@ "resolved": "https://registry.npmjs.org/loopback-datasource-juggler/-/loopback-datasource-juggler-5.2.1.tgz", "integrity": "sha512-AZr2i/bmlxJi9OM+9GdS0nPvbS6O/LNORqXE+IdQcjGDmMVKQZr2YLeNJiWU1kyHIHtQ7Q+LeNHyPAa8Usei8w==", "license": "MIT", + "peer": true, "dependencies": { "async": "^3.2.6", "change-case": "^4.1.2", @@ -8237,6 +8241,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "license": "ISC", + "peer": true, "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -8252,6 +8257,7 @@ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.3" } @@ -8333,7 +8339,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -8797,6 +8802,7 @@ "resolved": "https://registry.npmjs.org/msgpack5/-/msgpack5-6.0.2.tgz", "integrity": "sha512-kBSpECAWslrciRF3jy6HkMckNa14j3VZwNUUe1ONO/yihs19MskiFnsWXm0Q0aPkDYDBRFvTKkEuEDY+HVxBvQ==", "license": "MIT", + "peer": true, "dependencies": { "bl": "^5.0.0", "inherits": "^2.0.3", @@ -8809,6 +8815,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8848,6 +8855,7 @@ } ], "license": "MIT", + "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8882,6 +8890,7 @@ "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10" } @@ -8898,6 +8907,7 @@ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "license": "MIT", + "peer": true, "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" @@ -11425,7 +11435,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12450,6 +12459,7 @@ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", "license": "MIT", + "peer": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -12548,6 +12558,7 @@ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", "license": "MIT", + "peer": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -12558,6 +12569,7 @@ "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", "license": "MIT", + "peer": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -13686,7 +13698,6 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -14292,6 +14303,7 @@ "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", "license": "MIT", + "peer": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", @@ -14663,6 +14675,7 @@ "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", "license": "MIT", + "peer": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -15544,7 +15557,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15776,7 +15788,6 @@ "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15917,6 +15928,7 @@ "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.3" } @@ -15926,6 +15938,7 @@ "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.3" } diff --git a/package.json b/package.json index 7f569c4..15266ba 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "eslint": "lb-eslint --report-unused-disable-directives .", "eslint:fix": "npm run eslint -- --fix", "pretest": "npm run build", - "test": "echo No Tests", + "test": "lb-mocha --allow-console-logs dist/__tests__/**/*.js", "posttest": "npm run lint", "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", "prepublishOnly": "npm run test", @@ -120,4 +120,4 @@ ], "repositoryUrl": "git@github.com:sourcefuse/loopback4-billing.git" } -} +} \ No newline at end of file diff --git a/src/__tests__/unit/stripe-subscription.service.unit.ts b/src/__tests__/unit/stripe-subscription.service.unit.ts new file mode 100644 index 0000000..415af5c --- /dev/null +++ b/src/__tests__/unit/stripe-subscription.service.unit.ts @@ -0,0 +1,643 @@ +import {expect, sinon} from '@loopback/testlab'; +import {StripeService} from '../../providers/sdk/stripe/stripe.service'; +import { + CollectionMethod, + ProrationBehavior, + RecurringInterval, + TPrice, + TProduct, + TSubscriptionCreate, + TSubscriptionUpdate, +} from '../../types'; + +// --------------------------------------------------------------------------- +// Helper types used inside tests only +// --------------------------------------------------------------------------- + +interface StubStripeSubscriptionItem { + id: string; +} + +interface StubStripeSubscription { + id: string; + status: string; + customer: string; + cancel_at_period_end: boolean; + current_period_start: number; + current_period_end: number; + items: {data: StubStripeSubscriptionItem[]}; +} + +interface StubStripeInvoice { + id: string; + status: string; +} + +interface StubStripeInvoiceDetail { + currency: string; + total: number; + total_tax_amounts: {amount: number}[]; +} + +interface StubStripePrice { + id: string; + currency: string; + unit_amount: number | null; + product: string; + recurring: {interval: string; interval_count: number} | null; + metadata: Record; + active: boolean; +} + +interface StubbedStripe { + products: { + create: sinon.SinonStub; + retrieve: sinon.SinonStub; + }; + prices: { + create: sinon.SinonStub; + }; + subscriptions: { + create: sinon.SinonStub; + retrieve: sinon.SinonStub; + update: sinon.SinonStub; + cancel: sinon.SinonStub; + }; + invoices: { + retrieve: sinon.SinonStub; + list: sinon.SinonStub; + sendInvoice: sinon.SinonStub; + voidInvoice: sinon.SinonStub; + finalizeInvoice: sinon.SinonStub; + }; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('StripeService - Subscription Management', () => { + let service: StripeService; + let sandbox: sinon.SinonSandbox; + let stripeStub: StubbedStripe; + + /** + * Create a fresh StripeService with all Stripe API calls stubbed out so no + * real network requests are made. + */ + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Instantiate with a dummy key (never reaches the network) + service = new StripeService({secretKey: 'sk_test_dummy'}); + + stripeStub = { + products: { + create: sandbox.stub(), + retrieve: sandbox.stub(), + }, + prices: { + create: sandbox.stub(), + }, + subscriptions: { + create: sandbox.stub(), + retrieve: sandbox.stub(), + update: sandbox.stub(), + cancel: sandbox.stub(), + }, + invoices: { + retrieve: sandbox.stub(), + list: sandbox.stub(), + sendInvoice: sandbox.stub(), + voidInvoice: sandbox.stub(), + finalizeInvoice: sandbox.stub(), + }, + }; + + // Override the protected stripe field so every SDK call hits a stub + (service as unknown as {stripe: StubbedStripe}).stripe = stripeStub; + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ------------------------------------------------------------------------- + // createProduct + // ------------------------------------------------------------------------- + + describe('createProduct', () => { + it('creates a product and returns its Stripe ID', async () => { + const product: TProduct = { + name: 'Enterprise Plan', + description: 'Full-featured enterprise subscription', + metadata: {tier: 'enterprise'}, + }; + + stripeStub.products.create.resolves({id: 'prod_enterprise_123'}); + + const result = await service.createProduct(product); + + expect(result).to.equal('prod_enterprise_123'); + sinon.assert.calledOnceWithExactly(stripeStub.products.create, { + name: product.name, + description: product.description, + metadata: product.metadata, + }); + }); + }); + + // ------------------------------------------------------------------------- + // createPrice + // ------------------------------------------------------------------------- + + describe('createPrice', () => { + it('creates a recurring price and maps the response to TPrice', async () => { + const priceInput: TPrice = { + currency: 'usd', + unitAmount: 4999, + product: 'prod_enterprise_123', + recurring: { + interval: RecurringInterval.MONTH, + intervalCount: 1, + }, + metadata: {plan: 'monthly'}, + }; + + const stripeResponse: StubStripePrice = { + id: 'price_monthly_456', + currency: 'usd', + unit_amount: 4999, + product: 'prod_enterprise_123', + recurring: {interval: 'month', interval_count: 1}, + metadata: {plan: 'monthly'}, + active: true, + }; + + stripeStub.prices.create.resolves(stripeResponse); + + const result = await service.createPrice(priceInput); + + expect(result.id).to.equal('price_monthly_456'); + expect(result.currency).to.equal('usd'); + expect(result.unitAmount).to.equal(4999); + expect(result.product).to.equal('prod_enterprise_123'); + expect(result.recurring?.interval).to.equal(RecurringInterval.MONTH); + expect(result.recurring?.intervalCount).to.equal(1); + expect(result.active).to.be.true(); + }); + + it('handles a one-time price (no recurring field)', async () => { + const priceInput: TPrice = { + currency: 'usd', + unitAmount: 999, + product: 'prod_setup_fee', + }; + + const stripeResponse: StubStripePrice = { + id: 'price_setup_789', + currency: 'usd', + unit_amount: 999, + product: 'prod_setup_fee', + recurring: null, + metadata: {}, + active: true, + }; + + stripeStub.prices.create.resolves(stripeResponse); + + const result = await service.createPrice(priceInput); + + expect(result.recurring).to.be.undefined(); + }); + }); + + // ------------------------------------------------------------------------- + // createSubscription + // ------------------------------------------------------------------------- + + describe('createSubscription', () => { + it('creates a subscription and returns its Stripe ID', async () => { + const subscriptionInput: TSubscriptionCreate = { + customerId: 'cus_tenant_abc', + priceRefId: 'price_monthly_456', + collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY, + }; + + stripeStub.subscriptions.create.resolves({id: 'sub_new_001'}); + + const result = await service.createSubscription(subscriptionInput); + + expect(result).to.equal('sub_new_001'); + sinon.assert.calledOnce(stripeStub.subscriptions.create); + + const callArg = stripeStub.subscriptions.create.firstCall.args[0]; + expect(callArg.customer).to.equal('cus_tenant_abc'); + expect(callArg.payment_behavior).to.equal('default_incomplete'); + }); + + it('passes daysUntilDue when collection method is send_invoice', async () => { + const subscriptionInput: TSubscriptionCreate = { + customerId: 'cus_tenant_xyz', + priceRefId: 'price_monthly_456', + collectionMethod: CollectionMethod.SEND_INVOICE, + daysUntilDue: 30, + }; + + stripeStub.subscriptions.create.resolves({id: 'sub_invoice_002'}); + + await service.createSubscription(subscriptionInput); + + const callArg = stripeStub.subscriptions.create.firstCall.args[0]; + expect(callArg.days_until_due).to.equal(30); + expect(callArg.collection_method).to.equal(CollectionMethod.SEND_INVOICE); + }); + + it('uses defaultPaymentBehavior from StripeConfig when provided', async () => { + // Create a separate service instance with a custom payment behavior so + // callers are not forced to use the SCA-default 'default_incomplete'. + const customService = new StripeService({ + secretKey: 'sk_test_dummy', + defaultPaymentBehavior: 'allow_incomplete', + }); + (customService as unknown as {stripe: StubbedStripe}).stripe = stripeStub; + + stripeStub.subscriptions.create.resolves({id: 'sub_custom_003'}); + + await customService.createSubscription({ + customerId: 'cus_custom', + priceRefId: 'price_abc', + collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY, + }); + + const callArg = stripeStub.subscriptions.create.firstCall.args[0]; + expect(callArg.payment_behavior).to.equal('allow_incomplete'); + }); + + it('falls back to default_incomplete when defaultPaymentBehavior is not configured', async () => { + stripeStub.subscriptions.create.resolves({id: 'sub_default_004'}); + + await service.createSubscription({ + customerId: 'cus_fallback', + priceRefId: 'price_fallback', + collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY, + }); + + const callArg = stripeStub.subscriptions.create.firstCall.args[0]; + expect(callArg.payment_behavior).to.equal('default_incomplete'); + }); + }); + + // ------------------------------------------------------------------------- + // getSubscription + // ------------------------------------------------------------------------- + + describe('getSubscription', () => { + it('retrieves and maps a subscription to TSubscriptionResult', async () => { + const stubSub: StubStripeSubscription = { + id: 'sub_active_001', + status: 'active', + customer: 'cus_tenant_abc', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + items: {data: [{id: 'si_001'}]}, + }; + + stripeStub.subscriptions.retrieve.resolves(stubSub); + + const result = await service.getSubscription('sub_active_001'); + + expect(result.id).to.equal('sub_active_001'); + expect(result.status).to.equal('active'); + expect(result.customerId).to.equal('cus_tenant_abc'); + expect(result.currentPeriodStart).to.equal(1700000000); + expect(result.currentPeriodEnd).to.equal(1702592000); + expect(result.cancelAtPeriodEnd).to.be.false(); + }); + }); + + // ------------------------------------------------------------------------- + // updateSubscription + // ------------------------------------------------------------------------- + + describe('updateSubscription', () => { + it('updates an active subscription in place with proration', async () => { + const activeSub: StubStripeSubscription = { + id: 'sub_active_001', + status: 'active', + customer: 'cus_tenant_abc', + cancel_at_period_end: false, + current_period_start: 1700000000, + current_period_end: 1702592000, + items: {data: [{id: 'si_item_001'}]}, + }; + const updatedSub: StubStripeSubscription = {...activeSub}; + + stripeStub.subscriptions.retrieve.resolves(activeSub); + stripeStub.subscriptions.update.resolves(updatedSub); + + const updates: TSubscriptionUpdate = { + priceRefId: 'price_pro_999', + prorationBehavior: ProrationBehavior.CREATE_PRORATIONS, + }; + + const result = await service.updateSubscription( + 'sub_active_001', + updates, + ); + + expect(result.id).to.equal('sub_active_001'); + expect(result.status).to.equal('active'); + + // Verify stripe.subscriptions.cancel was NOT called (active path) + sinon.assert.notCalled(stripeStub.subscriptions.cancel); + sinon.assert.calledOnce(stripeStub.subscriptions.update); + + const updateArg = stripeStub.subscriptions.update.firstCall.args[1]; + expect(updateArg.proration_behavior).to.equal( + ProrationBehavior.CREATE_PRORATIONS, + ); + expect(updateArg.items[0].price).to.equal('price_pro_999'); + }); + + it('cancels an incomplete subscription and creates a replacement', async () => { + const incompleteSub: StubStripeSubscription = { + id: 'sub_incomplete_007', + status: 'incomplete', + customer: 'cus_tenant_abc', + cancel_at_period_end: false, + current_period_start: 0, + current_period_end: 0, + items: {data: [{id: 'si_item_007'}]}, + }; + + stripeStub.subscriptions.retrieve.resolves(incompleteSub); + stripeStub.subscriptions.cancel.resolves({}); + stripeStub.subscriptions.create.resolves({id: 'sub_replacement_008'}); + + const updates: TSubscriptionUpdate = {priceRefId: 'price_pro_999'}; + + const result = await service.updateSubscription( + 'sub_incomplete_007', + updates, + ); + + // a new subscription ID should be returned + expect(result.id).to.equal('sub_replacement_008'); + expect(result.status).to.equal('incomplete'); + + sinon.assert.calledOnce(stripeStub.subscriptions.cancel); + sinon.assert.calledOnce(stripeStub.subscriptions.create); + }); + }); + + // ------------------------------------------------------------------------- + // cancelSubscription + // ------------------------------------------------------------------------- + + describe('cancelSubscription', () => { + it('cancels the subscription and voids open invoices', async () => { + const openInvoice: StubStripeInvoice = { + id: 'in_open_001', + status: 'open', + }; + + stripeStub.subscriptions.cancel.resolves({}); + stripeStub.invoices.list.resolves({data: [openInvoice]}); + stripeStub.invoices.voidInvoice.resolves({}); + + await service.cancelSubscription('sub_active_001'); + + sinon.assert.calledOnce(stripeStub.subscriptions.cancel); + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.voidInvoice, + 'in_open_001', + ); + sinon.assert.notCalled(stripeStub.invoices.finalizeInvoice); + }); + + it('finalizes then voids draft invoices on cancellation', async () => { + const draftInvoice: StubStripeInvoice = { + id: 'in_draft_002', + status: 'draft', + }; + + stripeStub.subscriptions.cancel.resolves({}); + stripeStub.invoices.list.resolves({data: [draftInvoice]}); + stripeStub.invoices.finalizeInvoice.resolves({}); + stripeStub.invoices.voidInvoice.resolves({}); + + await service.cancelSubscription('sub_active_001'); + + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.finalizeInvoice, + 'in_draft_002', + ); + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.voidInvoice, + 'in_draft_002', + ); + }); + + it('takes no invoice action for already-paid invoices', async () => { + const paidInvoice: StubStripeInvoice = { + id: 'in_paid_003', + status: 'paid', + }; + + stripeStub.subscriptions.cancel.resolves({}); + stripeStub.invoices.list.resolves({data: [paidInvoice]}); + + await service.cancelSubscription('sub_active_001'); + + sinon.assert.notCalled(stripeStub.invoices.voidInvoice); + sinon.assert.notCalled(stripeStub.invoices.finalizeInvoice); + }); + }); + + // ------------------------------------------------------------------------- + // pauseSubscription + // ------------------------------------------------------------------------- + + describe('pauseSubscription', () => { + it('pauses a subscription by setting mark_uncollectible behavior', async () => { + stripeStub.subscriptions.update.resolves({}); + + await service.pauseSubscription('sub_active_001'); + + sinon.assert.calledOnce(stripeStub.subscriptions.update); + const updateArg = stripeStub.subscriptions.update.firstCall.args[1]; + expect(updateArg.pause_collection?.behavior).to.equal( + 'mark_uncollectible', + ); + }); + }); + + // ------------------------------------------------------------------------- + // resumeSubscription + // ------------------------------------------------------------------------- + + describe('resumeSubscription', () => { + it('resumes a paused subscription by clearing pause_collection', async () => { + stripeStub.subscriptions.update.resolves({}); + + await service.resumeSubscription('sub_paused_001'); + + sinon.assert.calledOnce(stripeStub.subscriptions.update); + const callArg = stripeStub.subscriptions.update.firstCall.args[0]; + expect(callArg).to.equal('sub_paused_001'); + }); + }); + + // ------------------------------------------------------------------------- + // getInvoicePriceDetails + // ------------------------------------------------------------------------- + + describe('getInvoicePriceDetails', () => { + it('returns a correctly computed invoice price breakdown', async () => { + const fakeInvoice: StubStripeInvoiceDetail = { + currency: 'usd', + total: 5999, + total_tax_amounts: [{amount: 500}, {amount: 299}], + }; + + stripeStub.invoices.retrieve.resolves(fakeInvoice); + + const result = await service.getInvoicePriceDetails('in_123'); + + expect(result.currency).to.equal('USD'); + expect(result.totalAmount).to.equal(5999); + expect(result.taxAmount).to.equal(799); // 500 + 299 + expect(result.amountExcludingTax).to.equal(5200); // 5999 - 799 + }); + + it('returns zero tax when total_tax_amounts is empty', async () => { + stripeStub.invoices.retrieve.resolves({ + currency: 'eur', + total: 2000, + total_tax_amounts: [], + }); + + const result = await service.getInvoicePriceDetails('in_no_tax'); + + expect(result.taxAmount).to.equal(0); + expect(result.amountExcludingTax).to.equal(2000); + }); + }); + + // ------------------------------------------------------------------------- + // sendPaymentLink + // ------------------------------------------------------------------------- + + describe('sendPaymentLink', () => { + it('calls stripe.invoices.sendInvoice with the correct invoice ID', async () => { + // Stub retrieve to return a send_invoice, finalized (open) invoice + stripeStub.invoices.retrieve.resolves({ + id: 'in_link_001', + status: 'open', + collection_method: 'send_invoice', + }); + stripeStub.invoices.sendInvoice.resolves({}); + + await service.sendPaymentLink('in_link_001'); + + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.sendInvoice, + 'in_link_001', + ); + }); + + it('finalizes a draft send_invoice before sending', async () => { + stripeStub.invoices.retrieve.resolves({ + id: 'in_draft_001', + status: 'draft', + collection_method: 'send_invoice', + }); + stripeStub.invoices.finalizeInvoice.resolves({}); + stripeStub.invoices.sendInvoice.resolves({}); + + await service.sendPaymentLink('in_draft_001'); + + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.finalizeInvoice, + 'in_draft_001', + ); + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.sendInvoice, + 'in_draft_001', + ); + }); + + it('skips sendInvoice for charge_automatically invoices', async () => { + stripeStub.invoices.retrieve.resolves({ + id: 'in_auto_001', + status: 'open', + collection_method: 'charge_automatically', + }); + + await service.sendPaymentLink('in_auto_001'); + + sinon.assert.notCalled(stripeStub.invoices.sendInvoice); + }); + + it('finalizes draft charge_automatically invoice without sending', async () => { + stripeStub.invoices.retrieve.resolves({ + id: 'in_auto_draft_001', + status: 'draft', + collection_method: 'charge_automatically', + }); + stripeStub.invoices.finalizeInvoice.resolves({}); + + await service.sendPaymentLink('in_auto_draft_001'); + + sinon.assert.calledOnceWithExactly( + stripeStub.invoices.finalizeInvoice, + 'in_auto_draft_001', + ); + sinon.assert.notCalled(stripeStub.invoices.sendInvoice); + }); + }); + + // ------------------------------------------------------------------------- + // checkProductExists + // ------------------------------------------------------------------------- + + describe('checkProductExists', () => { + it('returns true when the product exists and is active', async () => { + stripeStub.products.retrieve.resolves({active: true}); + + const result = await service.checkProductExists('prod_active_001'); + + expect(result).to.be.true(); + }); + + it('returns false when the product is archived (active: false)', async () => { + stripeStub.products.retrieve.resolves({active: false}); + + const result = await service.checkProductExists('prod_archived_002'); + + expect(result).to.be.false(); + }); + + it('returns false when Stripe signals resource_missing', async () => { + const notFoundError = Object.assign(new Error('No such product'), { + code: 'resource_missing', + }); + stripeStub.products.retrieve.rejects(notFoundError); + + const result = await service.checkProductExists('prod_gone_003'); + + expect(result).to.be.false(); + }); + + it('re-throws unexpected errors from Stripe', async () => { + const networkError = new Error('Network failure'); + stripeStub.products.retrieve.rejects(networkError); + + await expect( + service.checkProductExists('prod_error_004'), + ).to.be.rejectedWith('Network failure'); + }); + }); +}); diff --git a/src/keys.ts b/src/keys.ts index c1fd1cd..ee14b9d 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -1,6 +1,6 @@ import {BindingKey, CoreBindings} from '@loopback/core'; import {BillingComponent} from './component'; -import {IService} from './types'; +import {IService, ISubscriptionService} from './types'; /** * Binding keys used by this component. @@ -12,4 +12,13 @@ export namespace BillingComponentBindings { export const BillingProvider = BindingKey.create('sf.billing'); export const SDKProvider = BindingKey.create('sf.billing.sdk'); export const RestProvider = BindingKey.create('sf.billing.rest'); + /** + * Binding key for a provider that implements the full subscription lifecycle + * ({@link ISubscriptionService}). Bind your extended StripeService (or any + * other gateway implementation) here so controllers and services can inject + * subscription capabilities independently of one-time billing. + */ + export const SubscriptionProvider = BindingKey.create( + 'sf.billing.subscription', + ); } diff --git a/src/providers/billing.provider.ts b/src/providers/billing.provider.ts index 4ab4333..86f0518 100644 --- a/src/providers/billing.provider.ts +++ b/src/providers/billing.provider.ts @@ -99,29 +99,6 @@ export class BillingProvider implements Provider { } value() { - return { - createCustomer: async (customerDto: TCustomer) => - this.createCustomer(customerDto), - getCustomers: (customerId: string) => this.getCustomers(customerId), - updateCustomerById: (tenantId: string, customerDto: Partial) => - this.updateCustomerById(tenantId, customerDto), - deleteCustomer: (customerId: string) => this.deleteCustomer(customerId), - createPaymentSource: (paymentDto: TPaymentSource) => - this.createPaymentSource(paymentDto), - applyPaymentSourceForInvoice: ( - invoiceId: string, - transaction: Transaction, - ) => this.applyPaymentSourceForInvoice(invoiceId, transaction), - retrievePaymentSource: (paymentSourceId: string) => - this.retrievePaymentSource(paymentSourceId), - deletePaymentSource: (paymentSourceId: string) => - this.deletePaymentSource(paymentSourceId), - createInvoice: (invoice: TInvoice) => this.createInvoice(invoice), - retrieveInvoice: (invoiceId: string) => this.retrieveInvoice(invoiceId), - updateInvoice: (invoiceId: string, invoice: Partial) => - this.updateInvoice(invoiceId, invoice), - deleteInvoice: (invoiceId: string) => this.deleteInvoice(invoiceId), - getPaymentStatus: (invoiceId: string) => this.getPaymentStatus(invoiceId), - }; + return this.getProvider(); } } diff --git a/src/providers/sdk/stripe/adapter/index.ts b/src/providers/sdk/stripe/adapter/index.ts index 353cecf..ed2e797 100644 --- a/src/providers/sdk/stripe/adapter/index.ts +++ b/src/providers/sdk/stripe/adapter/index.ts @@ -1,3 +1,4 @@ export * from './customer.adapter'; export * from './invoice.adapter'; export * from './payment-source.adapter'; +export * from './subscription.adapter'; diff --git a/src/providers/sdk/stripe/adapter/subscription.adapter.ts b/src/providers/sdk/stripe/adapter/subscription.adapter.ts new file mode 100644 index 0000000..fb12150 --- /dev/null +++ b/src/providers/sdk/stripe/adapter/subscription.adapter.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import Stripe from 'stripe'; +import { + IAdapter, + TSubscriptionCreate, + TSubscriptionResult, +} from '../../../../types'; + +/** + * Adapter that converts between the Stripe Subscription SDK object and the + * provider-agnostic {@link TSubscriptionResult} shape used throughout the + * library. + * + * Library consumers can subclass this adapter and re-bind it to customise the + * mapping — e.g. to expose additional Stripe-specific fields — without + * modifying {@link StripeService}. + * + * @example + * ```ts + * class MySubscriptionAdapter extends StripeSubscriptionAdapter { + * adaptToModel(resp: Stripe.Subscription): TSubscriptionResult { + * return { ...super.adaptToModel(resp), trialEnd: resp.trial_end }; + * } + * } + * ``` + */ +export class StripeSubscriptionAdapter + implements IAdapter +{ + /** + * Maps a raw Stripe Subscription object to the normalised + * {@link TSubscriptionResult}. + * + * @param resp - Raw Stripe Subscription returned by the SDK. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + adaptToModel(resp: any): TSubscriptionResult { + const sub = resp as Stripe.Subscription; + return { + id: sub.id, + status: sub.status, + customerId: + typeof sub.customer === 'string' ? sub.customer : sub.customer?.id, + currentPeriodStart: sub.current_period_start, + currentPeriodEnd: sub.current_period_end, + cancelAtPeriodEnd: sub.cancel_at_period_end, + }; + } + + /** + * Maps a {@link TSubscriptionCreate} to the Stripe subscription create + * parameters. + * + * @param data - Provider-agnostic subscription creation payload. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + adaptFromModel(data: Partial): any { + return { + customer: data.customerId, + items: data.priceRefId ? [{price: data.priceRefId}] : [], + collection_method: data.collectionMethod, + ...(data.daysUntilDue !== undefined && { + days_until_due: data.daysUntilDue, + }), + }; + } +} diff --git a/src/providers/sdk/stripe/stripe.service.ts b/src/providers/sdk/stripe/stripe.service.ts index 95b36a5..ec782c8 100644 --- a/src/providers/sdk/stripe/stripe.service.ts +++ b/src/providers/sdk/stripe/stripe.service.ts @@ -1,11 +1,23 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {inject} from '@loopback/core'; import Stripe from 'stripe'; -import {TInvoice, Transaction} from '../../../types'; +import { + CollectionMethod, + RecurringInterval, + TInvoice, + TInvoicePrice, + TPrice, + TProduct, + TSubscriptionCreate, + TSubscriptionResult, + TSubscriptionUpdate, + Transaction, +} from '../../../types'; import { StripeCustomerAdapter, StripeInvoiceAdapter, StripePaymentAdapter, + StripeSubscriptionAdapter, } from './adapter'; import {StripeBindings} from './key'; import { @@ -16,10 +28,15 @@ import { StripeConfig, } from './type'; export class StripeService implements IStripeService { - private stripe: Stripe; + /** + * Stripe SDK instance. `protected` to allow subclasses (and test doubles) + * to substitute the instance without re-opening the class. + */ + protected stripe: Stripe; stripeCustomerAdapter: StripeCustomerAdapter; stripeInvoiceAdapter: StripeInvoiceAdapter; stripePaymentAdapter: StripePaymentAdapter; + stripeSubscriptionAdapter: StripeSubscriptionAdapter; constructor( @inject(StripeBindings.config, {optional: true}) @@ -31,6 +48,7 @@ export class StripeService implements IStripeService { this.stripeCustomerAdapter = new StripeCustomerAdapter(); this.stripeInvoiceAdapter = new StripeInvoiceAdapter(); this.stripePaymentAdapter = new StripePaymentAdapter(); + this.stripeSubscriptionAdapter = new StripeSubscriptionAdapter(); } async createCustomer(customerDto: IStripeCustomer): Promise { @@ -227,4 +245,262 @@ export class StripeService implements IStripeService { const invoice = await this.stripe.invoices.retrieve(invoiceId); return invoice.status === 'paid'; } + + // --------------------------------------------------------------------------- + // ISubscriptionService implementation + // --------------------------------------------------------------------------- + + /** + * Creates a new product in Stripe and returns the product's external ID. + * + * @param product - Product details (name, optional description and metadata). + * @returns The Stripe product ID. + */ + async createProduct(product: TProduct): Promise { + const created = await this.stripe.products.create({ + name: product.name, + description: product.description, + metadata: product.metadata, + }); + return created.id; + } + + /** + * Creates a recurring price in Stripe and returns the normalised {@link TPrice}. + * + * @param price - Price configuration including currency, amount and recurrence. + * @returns The created price with its Stripe-assigned ID. + */ + async createPrice(price: TPrice): Promise { + const created = await this.stripe.prices.create({ + currency: price.currency, + unit_amount: price.unitAmount, + product: price.product, + recurring: price.recurring + ? { + interval: price.recurring + .interval as Stripe.PriceCreateParams.Recurring.Interval, + interval_count: price.recurring.intervalCount, + } + : undefined, + metadata: price.metadata, + }); + + return { + id: created.id, + currency: created.currency, + unitAmount: created.unit_amount ?? 0, + product: + typeof created.product === 'string' + ? created.product + : (created.product?.id ?? ''), + recurring: created.recurring + ? { + interval: created.recurring.interval as RecurringInterval, + intervalCount: created.recurring.interval_count, + } + : undefined, + metadata: created.metadata as Record, + active: created.active, + }; + } + + /** + * Creates a new subscription in Stripe. + * + * Uses `payment_behavior: 'default_incomplete'` so the subscription starts + * in an `incomplete` state until the first payment is confirmed, which is + * the recommended Stripe pattern for SCA-compliant flows. + * + * @param subscription - Subscription parameters including customer, price and collection method. + * @returns The Stripe subscription ID. + */ + async createSubscription(subscription: TSubscriptionCreate): Promise { + const created = await this.stripe.subscriptions.create({ + customer: subscription.customerId, + items: [{price: subscription.priceRefId}], + collection_method: subscription.collectionMethod, + days_until_due: subscription.daysUntilDue, + payment_behavior: (this.stripeConfig.defaultPaymentBehavior ?? + 'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior, + }); + return created.id; + } + + /** + * Retrieves the current state of a subscription from Stripe. + * + * @param subscriptionId - The Stripe subscription ID. + * @returns A normalised {@link TSubscriptionResult}. + */ + async getSubscription(subscriptionId: string): Promise { + const subscription = + await this.stripe.subscriptions.retrieve(subscriptionId); + return this.stripeSubscriptionAdapter.adaptToModel(subscription); + } + + /** + * Upgrades or downgrades an existing subscription. + * + * Handles the edge case where a subscription is still `incomplete` (first + * payment not yet confirmed): the incomplete subscription is cancelled and a + * fresh one is created so the customer can retry payment. + * + * For active subscriptions the Stripe proration behaviour is controlled by + * {@link TSubscriptionUpdate.prorationBehavior}. + * + * @param subscriptionId - The Stripe subscription ID to modify. + * @param updates - The new price and optional proration behaviour. + * @returns A normalised {@link TSubscriptionResult} reflecting the change. + */ + async updateSubscription( + subscriptionId: string, + updates: TSubscriptionUpdate, + ): Promise { + const existing = await this.stripe.subscriptions.retrieve(subscriptionId); + + if (existing.status === 'incomplete') { + // Cancel the incomplete subscription and create a fresh one so the + // customer gets a new payment confirmation link. + await this.stripe.subscriptions.cancel(subscriptionId); + const newId = await this.createSubscription({ + customerId: existing.customer as string, + priceRefId: updates.priceRefId ?? '', + collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY, + }); + return { + id: newId, + status: 'incomplete', + customerId: existing.customer as string, + }; + } + + const priceItemId = existing.items.data[0].id; + const updated = await this.stripe.subscriptions.update(subscriptionId, { + proration_behavior: + updates.prorationBehavior as Stripe.SubscriptionUpdateParams.ProrationBehavior, + items: [{id: priceItemId, price: updates.priceRefId}], + }); + return this.stripeSubscriptionAdapter.adaptToModel(updated); + } + + /** + * Cancels a subscription immediately with proration. + * + * After cancellation any open invoices are voided and any draft invoices are + * finalised then voided, ensuring the customer is not charged for the + * remaining period. + * + * @param subscriptionId - The Stripe subscription ID to cancel. + */ + async cancelSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.cancel(subscriptionId); + + // Best-effort: void any remaining open/draft invoices after cancellation. + // Errors here should not fail the cancel response. + try { + const invoices = await this.stripe.invoices.list({ + subscription: subscriptionId, + }); + + await Promise.all( + invoices.data.map(async invoice => { + if (invoice.status === 'open' && invoice.id) { + return this.stripe.invoices.voidInvoice(invoice.id); + } else if (invoice.status === 'draft' && invoice.id) { + await this.stripe.invoices.finalizeInvoice(invoice.id); + return this.stripe.invoices.voidInvoice(invoice.id); + } else { + return Promise.resolve(); + } + }), + ); + } catch (_err) { + // Non-fatal — subscription is already cancelled in Stripe + } + } + + /** + * Pauses a subscription by marking future invoices as uncollectible. + * The subscription remains active in Stripe but no charges are attempted. + * + * @param subscriptionId - The Stripe subscription ID to pause. + */ + async pauseSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { + pause_collection: {behavior: 'mark_uncollectible'}, + }); + } + + /** + * Resumes a previously paused subscription by clearing the pause collection. + * + * @param subscriptionId - The Stripe subscription ID to resume. + */ + async resumeSubscription(subscriptionId: string): Promise { + await this.stripe.subscriptions.update(subscriptionId, { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + pause_collection: '' as any, // NOSONAR – Stripe uses empty string to clear pause_collection + }); + } + + /** + * Returns a detailed price breakdown for an invoice including tax and the + * amount excluding tax. + * + * @param invoiceId - The Stripe invoice ID. + * @returns {@link TInvoicePrice} with amounts in the invoice's minor currency unit. + */ + async getInvoicePriceDetails(invoiceId: string): Promise { + const invoice = await this.stripe.invoices.retrieve(invoiceId); + const taxAmount = + invoice.total_tax_amounts?.reduce((sum, tax) => sum + tax.amount, 0) ?? 0; + + return { + currency: invoice.currency.toUpperCase(), + totalAmount: invoice.total, + taxAmount, + amountExcludingTax: invoice.total - taxAmount, + }; + } + + /** + * Sends a hosted payment link for the given invoice to the customer's email. + * + * @param invoiceId - The Stripe invoice ID. + */ + async sendPaymentLink(invoiceId: string): Promise { + const invoice = await this.stripe.invoices.retrieve(invoiceId); + // sendInvoice is only valid for 'send_invoice' collection method. + // For 'charge_automatically' invoices, Stripe handles collection automatically; + // finalize the invoice if it is still a draft so it becomes collectable. + if (invoice.collection_method !== 'send_invoice') { + if (invoice.status === 'draft') { + await this.stripe.invoices.finalizeInvoice(invoiceId); + } + return; + } + if (invoice.status === 'draft') { + await this.stripe.invoices.finalizeInvoice(invoiceId); + } + await this.stripe.invoices.sendInvoice(invoiceId); + } + + /** + * Checks whether a product exists in Stripe and is currently active. + * + * @param productId - The Stripe product ID. + * @returns `true` if the product is active, `false` if it is archived or not found. + */ + async checkProductExists(productId: string): Promise { + try { + const product = await this.stripe.products.retrieve(productId); + return product.active === true; + } catch (error) { + if ((error as {code?: string}).code === 'resource_missing') { + return false; + } + throw error; + } + } } diff --git a/src/providers/sdk/stripe/type/index.ts b/src/providers/sdk/stripe/type/index.ts index e5fa47b..112af8a 100644 --- a/src/providers/sdk/stripe/type/index.ts +++ b/src/providers/sdk/stripe/type/index.ts @@ -1,11 +1,16 @@ -import {IService} from '../../../../types'; +import {IService, ISubscriptionService} from '../../../../types'; -export interface StripeConfig { - secretKey: string; -} - -export interface IStripeService extends IService {} +/** + * Full Stripe service interface combining one-time billing ({@link IService}) + * and recurring-subscription management ({@link ISubscriptionService}). + * + * Implementors can bind to {@link BillingComponentBindings.SDKProvider} for + * one-time billing OR to {@link BillingComponentBindings.SubscriptionProvider} + * for subscription operations, depending on their needs (ISP). + */ +export interface IStripeService extends IService, ISubscriptionService {} export * from './customer.type'; export * from './invoice.type'; export * from './payment-source.type'; +export * from './stripe-config.type'; diff --git a/src/providers/sdk/stripe/type/stripe-config.type.ts b/src/providers/sdk/stripe/type/stripe-config.type.ts new file mode 100644 index 0000000..3685c4e --- /dev/null +++ b/src/providers/sdk/stripe/type/stripe-config.type.ts @@ -0,0 +1,14 @@ +export interface StripeConfig { + secretKey: string; + /** + * Controls how Stripe handles payment during subscription creation. + * Defaults to `'default_incomplete'` (SCA-compliant: subscription starts + * incomplete until the first payment is confirmed). + * + * Set to `'allow_incomplete'` or `'error_if_incomplete'` to change the + * behaviour for your integration. + * + * @see https://stripe.com/docs/api/subscriptions/create#create_subscription-payment_behavior + */ + defaultPaymentBehavior?: string; +} diff --git a/src/types.ts b/src/types.ts index cf23d35..71b77f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,6 +119,174 @@ export type InvoiceStatus = | 'voided' | 'pending'; +// --------------------------------------------------------------------------- +// Subscription Management Types +// --------------------------------------------------------------------------- + +/** + * Supported billing collection methods for recurring subscriptions. + */ +export enum CollectionMethod { + CHARGE_AUTOMATICALLY = 'charge_automatically', + SEND_INVOICE = 'send_invoice', +} + +/** + * Supported recurring billing intervals. + */ +export enum RecurringInterval { + DAY = 'day', + WEEK = 'week', + MONTH = 'month', + YEAR = 'year', +} + +/** + * Controls how prorations are calculated when a subscription is updated. + */ +export enum ProrationBehavior { + CREATE_PRORATIONS = 'create_prorations', + NONE = 'none', + ALWAYS_INVOICE = 'always_invoice', +} + +/** + * Parameters required to create a product in the billing provider. + */ +export interface TProduct { + name: string; + description?: string; + metadata?: Record; +} + +/** + * Provider-agnostic representation of a recurring price / plan. + */ +export interface TPrice { + id?: string; + currency: string; + unitAmount: number; + /** External product ID that this price belongs to. */ + product: string; + recurring?: { + interval: RecurringInterval; + intervalCount: number; + }; + metadata?: Record; + active?: boolean; +} + +/** + * Parameters required to create a new subscription. + */ +export interface TSubscriptionCreate { + customerId: string; + /** Price / plan reference ID from the billing provider. */ + priceRefId: string; + collectionMethod: CollectionMethod; + /** Number of days after which the invoice is due (applicable for send_invoice). */ + daysUntilDue?: number; +} + +/** + * Parameters allowed when upgrading or downgrading an existing subscription. + */ +export interface TSubscriptionUpdate { + /** New price / plan reference ID. */ + priceRefId?: string; + prorationBehavior?: ProrationBehavior; +} + +/** + * Provider-agnostic subscription result returned after create / update / get. + */ +export interface TSubscriptionResult { + id: string; + status: string; + customerId: string; + currentPeriodStart?: number; + currentPeriodEnd?: number; + cancelAtPeriodEnd?: boolean; +} + +/** + * Detailed price breakdown of an invoice. + */ +export interface TInvoicePrice { + currency: string; + totalAmount: number; + taxAmount: number; + amountExcludingTax: number; +} + +/** + * Interface that any billing provider must implement to support the full + * recurring-subscription lifecycle. + * + * Keeps subscription concerns separated from one-time billing (IService), + * following the Interface Segregation Principle. + */ +export interface ISubscriptionService { + /** + * Creates a product in the billing provider and returns its external ID. + */ + createProduct(product: TProduct): Promise; + + /** + * Creates a price (recurring billing configuration) and returns the full price object. + */ + createPrice(price: TPrice): Promise; + + /** + * Creates a new recurring subscription and returns its external ID. + */ + createSubscription(subscription: TSubscriptionCreate): Promise; + + /** + * Retrieves the current state of a subscription by its external ID. + */ + getSubscription(subscriptionId: string): Promise; + + /** + * Upgrades or downgrades an active subscription. + * Handles the incomplete-subscription edge case automatically. + */ + updateSubscription( + subscriptionId: string, + updates: TSubscriptionUpdate, + ): Promise; + + /** + * Cancels a subscription immediately with proration and voids open invoices. + */ + cancelSubscription(subscriptionId: string): Promise; + + /** + * Pauses a subscription (marks outstanding invoices as uncollectible). + */ + pauseSubscription(subscriptionId: string): Promise; + + /** + * Resumes a previously paused subscription. + */ + resumeSubscription(subscriptionId: string): Promise; + + /** + * Returns a detailed price breakdown (total, tax, amount excluding tax) for an invoice. + */ + getInvoicePriceDetails(invoiceId: string): Promise; + + /** + * Sends the hosted payment link for a given invoice to the customer. + */ + sendPaymentLink(invoiceId: string): Promise; + + /** + * Checks whether a product exists and is still active in the billing provider. + */ + checkProductExists(productId: string): Promise; +} + export const enum ServiceType { SDK, REST, From ecde8274c2d1255f4bdc6d77c0463810cecc0293 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Wed, 25 Mar 2026 10:03:30 +0530 Subject: [PATCH 2/3] fix(provider): fix sonar issues --- src/providers/sdk/stripe/stripe.service.ts | 57 +++++++++++----------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/providers/sdk/stripe/stripe.service.ts b/src/providers/sdk/stripe/stripe.service.ts index ec782c8..1bf2ce2 100644 --- a/src/providers/sdk/stripe/stripe.service.ts +++ b/src/providers/sdk/stripe/stripe.service.ts @@ -204,39 +204,37 @@ export class StripeService implements IStripeService { invoiceId: string, invoice: Partial, ): Promise { - // Create the update object conditionally based on which fields are defined const updateData: Stripe.InvoiceUpdateParams = {}; - if (invoice.shippingAddress) { - updateData.shipping_details = { - name: [ - invoice.shippingAddress.firstName ?? '', // Avoid 'undefined' in the name - invoice.shippingAddress.lastName ?? '', - ] - .join(' ') - .trim(), // Trim to avoid extra spaces - address: { - line1: invoice.shippingAddress.line1 ?? undefined, // Only set if defined - line2: invoice.shippingAddress.line2 ?? undefined, - city: invoice.shippingAddress.city ?? undefined, - state: invoice.shippingAddress.state ?? undefined, - postal_code: invoice.shippingAddress.zip ?? undefined, - country: invoice.shippingAddress.country ?? undefined, - }, - phone: invoice.shippingAddress.phone ?? undefined, // Only set phone if provided - }; + updateData.shipping_details = this.buildShippingDetails( + invoice.shippingAddress, + ); } - - // Call the Stripe API with the built update data const updatedInvoice = await this.stripe.invoices.update( invoiceId, updateData, ); - - // Adapt the updated invoice to your model return this.stripeInvoiceAdapter.adaptToModel(updatedInvoice); } + private buildShippingDetails( + addr: IStripeInvoice['shippingAddress'], + ): Stripe.InvoiceUpdateParams.ShippingDetails { + const name = [addr?.firstName ?? '', addr?.lastName ?? ''].join(' ').trim(); + return { + name, + address: { + line1: addr?.line1, + line2: addr?.line2, + city: addr?.city, + state: addr?.state, + postal_code: addr?.zip, + country: addr?.country, + }, + phone: addr?.phone, + }; + } + async deleteInvoice(invoiceId: string): Promise { await this.stripe.invoices.del(invoiceId); } @@ -407,16 +405,19 @@ export class StripeService implements IStripeService { invoices.data.map(async invoice => { if (invoice.status === 'open' && invoice.id) { return this.stripe.invoices.voidInvoice(invoice.id); - } else if (invoice.status === 'draft' && invoice.id) { + } + if (invoice.status === 'draft' && invoice.id) { await this.stripe.invoices.finalizeInvoice(invoice.id); return this.stripe.invoices.voidInvoice(invoice.id); - } else { - return Promise.resolve(); } }), ); - } catch (_err) { - // Non-fatal — subscription is already cancelled in Stripe + } catch (err) { + // Non-fatal — subscription is already cancelled in Stripe; log for observability + console.info( + '[StripeService] cancelSubscription: invoice cleanup failed', + err, + ); } } From 2d8499c2b1cbd0ba4b9d038a3463ffc11a264027 Mon Sep 17 00:00:00 2001 From: Piyush Singh Gaur Date: Wed, 25 Mar 2026 17:41:14 +0530 Subject: [PATCH 3/3] feat(provider): implement a comprehensive subscription lifecycle in stripe Implement a comprehensive subscription lifecycle within loopback4-billing to support automatedrecurring billing, including subscription creation, upgrades and downgrades, renewals,cancellations, and proration, ensuring consistency and scalability for SaaS monetization GH-0 --- .../chargebee-subscription.service.unit.ts | 499 ++++++++++++++++++ src/providers/sdk/chargebee/adapter/index.ts | 1 + .../chargebee/adapter/subscription.adapter.ts | 75 +++ .../sdk/chargebee/charge-bee.service.ts | 353 ++++++++++++- .../chargebee/type/chargebee-config.type.ts | 38 ++ src/providers/sdk/chargebee/type/index.ts | 26 +- 6 files changed, 977 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/unit/chargebee-subscription.service.unit.ts create mode 100644 src/providers/sdk/chargebee/adapter/subscription.adapter.ts create mode 100644 src/providers/sdk/chargebee/type/chargebee-config.type.ts diff --git a/src/__tests__/unit/chargebee-subscription.service.unit.ts b/src/__tests__/unit/chargebee-subscription.service.unit.ts new file mode 100644 index 0000000..baaf73e --- /dev/null +++ b/src/__tests__/unit/chargebee-subscription.service.unit.ts @@ -0,0 +1,499 @@ +import {expect, sinon} from '@loopback/testlab'; +import chargebee from 'chargebee'; +import {ChargeBeeService} from '../../providers/sdk/chargebee/charge-bee.service'; +import { + CollectionMethod, + ProrationBehavior, + RecurringInterval, + TPrice, + TProduct, + TSubscriptionCreate, + TSubscriptionUpdate, +} from '../../types'; + +// --------------------------------------------------------------------------- +// Helper — builds a fake Chargebee subscription object +// --------------------------------------------------------------------------- + +function makeSubscription(overrides: object = {}) { + return { + id: 'sub_cb_001', + status: 'active', + customer_id: 'cust_tenant_abc', + current_term_start: 1700000000, + current_term_end: 1702592000, + cancel_at_period_end: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('ChargeBeeService - Subscription Management', () => { + let service: ChargeBeeService; + let sandbox: sinon.SinonSandbox; + + /** + * Stub every chargebee API call so no network requests are made. + * The chargebee SDK uses a builder pattern: chargebee.resource.action(params).request() + * so each stub must return an object with a `.request` stub. + * Cast through `unknown` to satisfy the strict ChargebeeRequest generic type. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function stubCb(returnValue: object): any { + // NOSONAR + return { + request: sinon.stub().resolves(returnValue), + setIdempotencyKey: sinon.stub().returnsThis(), + headers: sinon.stub().returnsThis(), + }; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Instantiate with dummy config — chargebee.configure is stubbed below + sandbox.stub(chargebee, 'configure'); + service = new ChargeBeeService({site: 'test-site', apiKey: 'test-key'}); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // ------------------------------------------------------------------------- + // createProduct + // ------------------------------------------------------------------------- + + describe('createProduct', () => { + it('creates a plan-type Item and returns its ID', async () => { + const itemStub = sandbox + .stub(chargebee.item, 'create') + .returns(stubCb({item: {id: 'enterprise-plan'}})); + + const product: TProduct = { + name: 'Enterprise Plan', + description: 'Full-featured tier', + metadata: {tier: 'enterprise'}, + }; + + const result = await service.createProduct(product); + + expect(result).to.equal('enterprise-plan'); + sinon.assert.calledOnce(itemStub); + + const callArg = itemStub.firstCall.args[0]; + // ID is derived from the name + expect(callArg.id).to.equal('enterprise-plan'); + expect(callArg.type).to.equal('plan'); + }); + + it('generates a URL-safe ID from the product name', async () => { + const itemStub = sandbox + .stub(chargebee.item, 'create') + .returns(stubCb({item: {id: 'my-saas-product'}})); + + await service.createProduct({name: 'My SaaS Product!'}); + + const callArg = itemStub.firstCall.args[0]; + expect(callArg.id).to.equal('my-saas-product'); + }); + + it('uses defaultItemFamilyId from config when provided', async () => { + const customService = new ChargeBeeService({ + site: 'test-site', + apiKey: 'test-key', + defaultItemFamilyId: 'saas-plans', + }); + const itemStub = sandbox + .stub(chargebee.item, 'create') + .returns(stubCb({item: {id: 'enterprise-plan'}})); + + await customService.createProduct({name: 'Enterprise Plan'}); + + const callArg = itemStub.firstCall.args[0]; + expect(callArg.item_family_id).to.equal('saas-plans'); + }); + + it('falls back to "default" item_family_id when not configured', async () => { + const itemStub = sandbox + .stub(chargebee.item, 'create') + .returns(stubCb({item: {id: 'enterprise-plan'}})); + + await service.createProduct({name: 'Enterprise Plan'}); + + const callArg = itemStub.firstCall.args[0]; + expect(callArg.item_family_id).to.equal('default'); + }); + }); + + // ------------------------------------------------------------------------- + // createPrice + // ------------------------------------------------------------------------- + + describe('createPrice', () => { + it('creates an ItemPrice and maps the response to TPrice', async () => { + const itemPriceResponse = { + item_price: { + id: 'enterprise-plan-usd-monthly', + item_id: 'enterprise-plan', + currency_code: 'USD', + price: 4999, + period_unit: 'month', + period: 1, + status: 'active', + }, + }; + + sandbox + .stub(chargebee.item_price, 'create') + .returns(stubCb(itemPriceResponse)); + + const priceInput: TPrice = { + currency: 'usd', + unitAmount: 4999, + product: 'enterprise-plan', + recurring: {interval: RecurringInterval.MONTH, intervalCount: 1}, + }; + + const result = await service.createPrice(priceInput); + + expect(result.id).to.equal('enterprise-plan-usd-monthly'); + expect(result.currency).to.equal('usd'); + expect(result.unitAmount).to.equal(4999); + expect(result.product).to.equal('enterprise-plan'); + expect(result.recurring?.interval).to.equal(RecurringInterval.MONTH); + expect(result.recurring?.intervalCount).to.equal(1); + expect(result.active).to.be.true(); + }); + + it('returns undefined recurring when no period_unit is set', async () => { + sandbox.stub(chargebee.item_price, 'create').returns( + stubCb({ + item_price: { + id: 'setup-fee', + item_id: 'setup', + currency_code: 'USD', + price: 999, + period_unit: null, + period: null, + status: 'active', + }, + }), + ); + + const result = await service.createPrice({ + currency: 'usd', + unitAmount: 999, + product: 'setup', + }); + + expect(result.recurring).to.be.undefined(); + }); + }); + + // ------------------------------------------------------------------------- + // createSubscription + // ------------------------------------------------------------------------- + + describe('createSubscription', () => { + it('creates a subscription and returns its Chargebee ID', async () => { + const createStub = sandbox + .stub(chargebee.subscription, 'create_with_items') + .returns(stubCb({subscription: makeSubscription()})); + + const subscriptionInput: TSubscriptionCreate = { + customerId: 'cust_tenant_abc', + priceRefId: 'enterprise-plan-usd-monthly', + collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY, + }; + + const result = await service.createSubscription(subscriptionInput); + + expect(result).to.equal('sub_cb_001'); + sinon.assert.calledOnce(createStub); + + const [customerId, params] = createStub.firstCall.args; + expect(customerId).to.equal('cust_tenant_abc'); + expect(params.subscription_items[0].item_price_id).to.equal( + 'enterprise-plan-usd-monthly', + ); + expect(params.auto_collection).to.equal('on'); + }); + + it('sets auto_collection off and net_term_days for send_invoice', async () => { + const createStub = sandbox + .stub(chargebee.subscription, 'create_with_items') + .returns(stubCb({subscription: makeSubscription()})); + + await service.createSubscription({ + customerId: 'cust_tenant_abc', + priceRefId: 'enterprise-plan-usd-monthly', + collectionMethod: CollectionMethod.SEND_INVOICE, + daysUntilDue: 14, + }); + + const [, params] = createStub.firstCall.args; + expect(params.auto_collection).to.equal('off'); + expect(params.net_term_days).to.equal(14); + }); + }); + + // ------------------------------------------------------------------------- + // getSubscription + // ------------------------------------------------------------------------- + + describe('getSubscription', () => { + it('retrieves and maps a subscription to TSubscriptionResult', async () => { + sandbox + .stub(chargebee.subscription, 'retrieve') + .returns(stubCb({subscription: makeSubscription()})); + + const result = await service.getSubscription('sub_cb_001'); + + expect(result.id).to.equal('sub_cb_001'); + expect(result.status).to.equal('active'); + expect(result.customerId).to.equal('cust_tenant_abc'); + expect(result.currentPeriodStart).to.equal(1700000000); + expect(result.currentPeriodEnd).to.equal(1702592000); + expect(result.cancelAtPeriodEnd).to.be.false(); + }); + }); + + // ------------------------------------------------------------------------- + // updateSubscription + // ------------------------------------------------------------------------- + + describe('updateSubscription', () => { + it('upgrades a subscription to a new item price', async () => { + const updateStub = sandbox + .stub(chargebee.subscription, 'update_for_items') + .returns(stubCb({subscription: makeSubscription()})); + + const updates: TSubscriptionUpdate = { + priceRefId: 'pro-plan-usd-monthly', + prorationBehavior: ProrationBehavior.CREATE_PRORATIONS, + }; + + const result = await service.updateSubscription('sub_cb_001', updates); + + expect(result.id).to.equal('sub_cb_001'); + sinon.assert.calledOnce(updateStub); + + const [subId, params] = updateStub.firstCall.args; + expect(subId).to.equal('sub_cb_001'); + expect(params.subscription_items[0].item_price_id).to.equal( + 'pro-plan-usd-monthly', + ); + }); + + it('disables proration when prorationBehavior is none', async () => { + const updateStub = sandbox + .stub(chargebee.subscription, 'update_for_items') + .returns(stubCb({subscription: makeSubscription()})); + + await service.updateSubscription('sub_cb_001', { + priceRefId: 'basic-plan-usd', + prorationBehavior: ProrationBehavior.NONE, + }); + + const [, params] = updateStub.firstCall.args; + // ProrationBehavior.NONE -> prorate: false + expect(params!.prorate).to.be.false(); + }); + }); + + // ------------------------------------------------------------------------- + // cancelSubscription + // ------------------------------------------------------------------------- + + describe('cancelSubscription', () => { + it('cancels a subscription immediately with customer_request reason', async () => { + const cancelStub = sandbox + .stub(chargebee.subscription, 'cancel_for_items') + .returns( + stubCb({subscription: makeSubscription({status: 'cancelled'})}), + ); + + await service.cancelSubscription('sub_cb_001'); + + sinon.assert.calledOnce(cancelStub); + const [subId, params] = cancelStub.firstCall.args as [ + string, + Record, + ]; + expect(subId).to.equal('sub_cb_001'); + expect(params.end_of_term).to.be.false(); + expect(params.cancel_reason_code).to.equal('customer_request'); + }); + + it('uses cancelAtEndOfTerm and defaultCancelReasonCode from config when provided', async () => { + const customService = new ChargeBeeService({ + site: 'test-site', + apiKey: 'test-key', + cancelAtEndOfTerm: true, + defaultCancelReasonCode: 'not_paid', + }); + const cancelStub = sandbox + .stub(chargebee.subscription, 'cancel_for_items') + .returns( + stubCb({subscription: makeSubscription({status: 'cancelled'})}), + ); + + await customService.cancelSubscription('sub_cb_config'); + + const [, params] = cancelStub.firstCall.args as [ + string, + Record, + ]; + expect(params.end_of_term).to.be.true(); + expect(params.cancel_reason_code).to.equal('not_paid'); + }); + }); + + // ------------------------------------------------------------------------- + // pauseSubscription + // ------------------------------------------------------------------------- + + describe('pauseSubscription', () => { + it('pauses a subscription', async () => { + const pauseStub = sandbox + .stub(chargebee.subscription, 'pause') + .returns(stubCb({subscription: makeSubscription({status: 'paused'})})); + + await service.pauseSubscription('sub_cb_001'); + + sinon.assert.calledOnceWithExactly(pauseStub, 'sub_cb_001', {}); + }); + }); + + // ------------------------------------------------------------------------- + // resumeSubscription + // ------------------------------------------------------------------------- + + describe('resumeSubscription', () => { + it('resumes a paused subscription', async () => { + const resumeStub = sandbox + .stub(chargebee.subscription, 'resume') + .returns(stubCb({subscription: makeSubscription()})); + + await service.resumeSubscription('sub_cb_001'); + + sinon.assert.calledOnceWithExactly(resumeStub, 'sub_cb_001', {}); + }); + }); + + // ------------------------------------------------------------------------- + // getInvoicePriceDetails + // ------------------------------------------------------------------------- + + describe('getInvoicePriceDetails', () => { + it('returns correctly computed price breakdown', async () => { + sandbox.stub(chargebee.invoice, 'retrieve').returns( + stubCb({ + invoice: { + currency_code: 'usd', + total: 5999, + tax: 499, + }, + }), + ); + + const result = await service.getInvoicePriceDetails('inv_cb_001'); + + expect(result.currency).to.equal('USD'); + expect(result.totalAmount).to.equal(5999); + expect(result.taxAmount).to.equal(499); + expect(result.amountExcludingTax).to.equal(5500); + }); + + it('handles zero tax gracefully', async () => { + sandbox.stub(chargebee.invoice, 'retrieve').returns( + stubCb({ + invoice: {currency_code: 'eur', total: 2000, tax: 0}, + }), + ); + + const result = await service.getInvoicePriceDetails('inv_cb_zero_tax'); + + expect(result.taxAmount).to.equal(0); + expect(result.amountExcludingTax).to.equal(2000); + }); + }); + + // ------------------------------------------------------------------------- + // sendPaymentLink + // ------------------------------------------------------------------------- + + describe('sendPaymentLink', () => { + it('calls chargebee.invoice.collect_payment to trigger payment link delivery', async () => { + const collectStub = sandbox + .stub(chargebee.invoice, 'collect_payment') + .returns(stubCb({invoice: {id: 'inv_cb_001'}})); + + await service.sendPaymentLink('inv_cb_001'); + + sinon.assert.calledOnce(collectStub); + const [invoiceId] = collectStub.firstCall.args; + expect(invoiceId).to.equal('inv_cb_001'); + }); + }); + + // ------------------------------------------------------------------------- + // checkProductExists + // ------------------------------------------------------------------------- + + describe('checkProductExists', () => { + it('returns true when the item is active', async () => { + sandbox + .stub(chargebee.item, 'retrieve') + .returns(stubCb({item: {id: 'enterprise-plan', status: 'active'}})); + + const result = await service.checkProductExists('enterprise-plan'); + + expect(result).to.be.true(); + }); + + it('returns false when the item is archived', async () => { + sandbox + .stub(chargebee.item, 'retrieve') + .returns(stubCb({item: {id: 'old-plan', status: 'archived'}})); + + const result = await service.checkProductExists('old-plan'); + + expect(result).to.be.false(); + }); + + it('returns false when Chargebee signals resource_not_found', async () => { + sandbox + .stub(chargebee.item, 'retrieve') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .returns({ + request: sinon.stub().rejects({api_error_code: 'resource_not_found'}), + setIdempotencyKey: sinon.stub().returnsThis(), + headers: sinon.stub().returnsThis(), + } as any); // NOSONAR + + const result = await service.checkProductExists('missing-plan'); + + expect(result).to.be.false(); + }); + + it('re-throws unexpected errors from Chargebee', async () => { + sandbox + .stub(chargebee.item, 'retrieve') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .returns({ + request: sinon.stub().rejects(new Error('Network timeout')), + setIdempotencyKey: sinon.stub().returnsThis(), + headers: sinon.stub().returnsThis(), + } as any); // NOSONAR + + await expect(service.checkProductExists('flaky-plan')).to.be.rejectedWith( + Error, + ); + }); + }); +}); diff --git a/src/providers/sdk/chargebee/adapter/index.ts b/src/providers/sdk/chargebee/adapter/index.ts index 353cecf..ed2e797 100644 --- a/src/providers/sdk/chargebee/adapter/index.ts +++ b/src/providers/sdk/chargebee/adapter/index.ts @@ -1,3 +1,4 @@ export * from './customer.adapter'; export * from './invoice.adapter'; export * from './payment-source.adapter'; +export * from './subscription.adapter'; diff --git a/src/providers/sdk/chargebee/adapter/subscription.adapter.ts b/src/providers/sdk/chargebee/adapter/subscription.adapter.ts new file mode 100644 index 0000000..8b87634 --- /dev/null +++ b/src/providers/sdk/chargebee/adapter/subscription.adapter.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + IAdapter, + TSubscriptionCreate, + TSubscriptionResult, +} from '../../../../types'; + +/** + * Adapter that converts between the raw Chargebee Subscription object and the + * provider-agnostic {@link TSubscriptionResult} shape used throughout the + * library. + * + * Library consumers can subclass this adapter and re-assign + * `service.chargebeeSubscriptionAdapter` to customise the mapping without + * modifying {@link ChargeBeeService}. + * + * @example + * ```ts + * class MyAdapter extends ChargebeeSubscriptionAdapter { + * adaptToModel(resp: unknown): TSubscriptionResult { + * const base = super.adaptToModel(resp); + * const raw = resp as {trial_end?: number}; + * return {...base, trialEnd: raw.trial_end}; + * } + * } + * // then: + * service.chargebeeSubscriptionAdapter = new MyAdapter(); + * ``` + */ +export class ChargebeeSubscriptionAdapter + implements IAdapter +{ + /** + * Maps a raw Chargebee Subscription object to the normalised + * {@link TSubscriptionResult}. + * + * @param resp - Raw Chargebee Subscription returned by the SDK. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + adaptToModel(resp: any): TSubscriptionResult { + // NOSONAR + return { + id: resp.id, + status: resp.status, + customerId: resp.customer_id, + currentPeriodStart: resp.current_term_start, + currentPeriodEnd: resp.current_term_end, + cancelAtPeriodEnd: resp.cancel_at_period_end ?? false, + }; + } + + /** + * Maps a {@link TSubscriptionCreate} to Chargebee `create_with_items` + * parameters. + * + * @param data - Provider-agnostic subscription creation payload. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + adaptFromModel(data: Partial): any { + return { + subscription_items: data.priceRefId + ? [{item_price_id: data.priceRefId}] + : [], + discounts: [], + ...(data.collectionMethod === 'send_invoice' + ? { + auto_collection: 'off' as const, + ...(data.daysUntilDue !== undefined && { + net_term_days: data.daysUntilDue, + }), + } + : {auto_collection: 'on' as const}), + }; + } +} diff --git a/src/providers/sdk/chargebee/charge-bee.service.ts b/src/providers/sdk/chargebee/charge-bee.service.ts index f98e19c..9f4fbb2 100644 --- a/src/providers/sdk/chargebee/charge-bee.service.ts +++ b/src/providers/sdk/chargebee/charge-bee.service.ts @@ -1,8 +1,23 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {inject} from '@loopback/core'; import chargebee from 'chargebee'; -import {Transaction} from '../../../types'; -import {CustomerAdapter, InvoiceAdapter, PaymentSourceAdapter} from './adapter'; +import { + CollectionMethod, + RecurringInterval, + TInvoicePrice, + TPrice, + TProduct, + TSubscriptionCreate, + TSubscriptionResult, + TSubscriptionUpdate, + Transaction, +} from '../../../types'; +import { + CustomerAdapter, + InvoiceAdapter, + PaymentSourceAdapter, + ChargebeeSubscriptionAdapter, +} from './adapter'; import {ChargeBeeBindings} from './key'; import { ChargeBeeConfig, @@ -16,18 +31,24 @@ export class ChargeBeeService implements IChargeBeeService { invoiceAdapter: InvoiceAdapter; customerAdapter: CustomerAdapter; paymentSource: PaymentSourceAdapter; + chargebeeSubscriptionAdapter: ChargebeeSubscriptionAdapter; constructor( @inject(ChargeBeeBindings.config, {optional: true}) private readonly chargeBeeConfig: ChargeBeeConfig, ) { - // config initialise - chargebee.configure({ - site: chargeBeeConfig.site, - api_key: chargeBeeConfig.apiKey, - }); + // Only configure the global chargebee singleton when a valid site is + // provided. This prevents a second instantiation with empty config + // (e.g. SDKProvider vs SubscriptionProvider) from resetting the site. + if (chargeBeeConfig?.site) { + chargebee.configure({ + site: chargeBeeConfig.site, + api_key: chargeBeeConfig.apiKey, + }); + } this.invoiceAdapter = new InvoiceAdapter(); this.customerAdapter = new CustomerAdapter(); this.paymentSource = new PaymentSourceAdapter(); + this.chargebeeSubscriptionAdapter = new ChargebeeSubscriptionAdapter(); } async createCustomer( customerDto: IChargeBeeCustomer, @@ -272,4 +293,322 @@ export class ChargeBeeService implements IChargeBeeService { throw new Error(JSON.stringify(error)); } } + + // --------------------------------------------------------------------------- + // ISubscriptionService implementation (Chargebee Items API v2) + // --------------------------------------------------------------------------- + + /** + * Creates a plan-type Item in Chargebee and returns its Item ID. + * + * Chargebee's equivalent of a Stripe Product is an `Item` with `type: plan`. + * + * @param product - Product details (name, optional description and metadata). + * @returns The Chargebee Item ID. + */ + async createProduct(product: TProduct): Promise { + try { + const itemId = product.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + + // Strip item_family_id from metadata — it is a top-level Chargebee param, + // and Chargebee rejects it if it appears inside metadata. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {item_family_id: _ignored, ...restMetadata} = (product.metadata ?? + {}) as Record; + const hasExtraMetadata = Object.keys(restMetadata).length > 0; + + const result = await chargebee.item + .create({ + id: itemId, + name: product.name, + description: product.description, + type: 'plan', + item_family_id: + (product.metadata?.['item_family_id'] as string) ?? + this.chargeBeeConfig.defaultItemFamilyId ?? + 'default', + // Only include metadata key if there are non-family fields to send. + // Passing metadata: undefined still serialises the key; use spread instead. + ...(hasExtraMetadata ? {metadata: restMetadata as object} : {}), + }) + .request(); + return result.item.id; + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Creates an ItemPrice (recurring price configuration) in Chargebee and + * returns the normalised {@link TPrice}. + * + * Chargebee's equivalent of a Stripe Price is an `ItemPrice`. + * + * @param price - Price configuration including currency, amount and recurrence. + * @returns The created price with its Chargebee-assigned ID. + */ + async createPrice(price: TPrice): Promise { + try { + // Chargebee requires an explicit ItemPrice ID or auto-generates one. + const priceId = + price.id ?? `${price.product}-${price.currency}-${Date.now()}`; + + const result = await chargebee.item_price + .create({ + id: priceId, + name: priceId, // Chargebee requires a display name + item_id: price.product, + currency_code: price.currency.toUpperCase(), + price: price.unitAmount, + pricing_model: (this.chargeBeeConfig.defaultPricingModel ?? + 'flat_fee') as + | 'flat_fee' + | 'per_unit' + | 'tiered' + | 'volume' + | 'stairstep', + period_unit: price.recurring?.interval as + | 'day' + | 'week' + | 'month' + | 'year' + | undefined, + period: price.recurring?.intervalCount, + tax_providers_fields: [], // Required by SDK type but can be empty + }) + .request(); + + const ip = result.item_price; + return { + id: ip.id, + currency: ip.currency_code.toLowerCase(), + unitAmount: ip.price ?? 0, + product: ip.item_id ?? price.product, + recurring: ip.period_unit + ? { + interval: ip.period_unit as RecurringInterval, + intervalCount: ip.period ?? 1, + } + : undefined, + active: ip.status === 'active', + }; + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Creates a new recurring subscription in Chargebee using the Items API. + * + * Uses `create_with_items` which maps to your `priceRefId` (ItemPrice ID). + * + * @param subscription - Subscription parameters. + * @returns The Chargebee Subscription ID. + */ + async createSubscription(subscription: TSubscriptionCreate): Promise { + try { + const result = await chargebee.subscription + .create_with_items(subscription.customerId, { + subscription_items: [ + { + item_price_id: subscription.priceRefId, + }, + ], + discounts: [], // Required by Chargebee SDK type + ...(subscription.collectionMethod === CollectionMethod.SEND_INVOICE + ? { + auto_collection: 'off' as const, + // Only include net_term_days if explicitly provided and site has payment terms configured + ...(subscription.daysUntilDue !== undefined + ? {net_term_days: subscription.daysUntilDue} + : {}), + } + : {auto_collection: 'on' as const}), + }) + .request(); + return result.subscription.id; + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Retrieves the current state of a subscription from Chargebee. + * + * @param subscriptionId - The Chargebee subscription ID. + * @returns A normalised {@link TSubscriptionResult}. + */ + async getSubscription(subscriptionId: string): Promise { + try { + const result = await chargebee.subscription + .retrieve(subscriptionId) + .request(); + return this.chargebeeSubscriptionAdapter.adaptToModel( + result.subscription, + ); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Upgrades or downgrades an active subscription in Chargebee. + * + * Uses `update_for_items` which applies immediate proration by default. + * Pass `prorationBehavior: 'none'` in `updates` to suppress proration. + * + * @param subscriptionId - The Chargebee subscription ID to modify. + * @param updates - The new ItemPrice ID and optional proration behaviour. + * @returns A normalised {@link TSubscriptionResult} reflecting the change. + */ + async updateSubscription( + subscriptionId: string, + updates: TSubscriptionUpdate, + ): Promise { + try { + const result = await chargebee.subscription + .update_for_items(subscriptionId, { + subscription_items: updates.priceRefId + ? [{item_price_id: updates.priceRefId}] + : [], + discounts: [], // Required by Chargebee SDK type + // When prorationBehavior is 'none', pass prorate:false to suppress credit notes + prorate: updates.prorationBehavior !== 'none', + }) + .request(); + return this.chargebeeSubscriptionAdapter.adaptToModel( + result.subscription, + ); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Cancels a subscription immediately in Chargebee. + * + * Sets `end_of_term: false` for immediate cancellation with proration credit + * (Chargebee applies a pro-rated credit note automatically). + * + * @param subscriptionId - The Chargebee subscription ID to cancel. + */ + async cancelSubscription(subscriptionId: string): Promise { + try { + await chargebee.subscription + .cancel_for_items(subscriptionId, { + end_of_term: this.chargeBeeConfig.cancelAtEndOfTerm ?? false, + cancel_reason_code: + this.chargeBeeConfig.defaultCancelReasonCode ?? 'customer_request', + }) + .request(); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Pauses a subscription in Chargebee. + * + * The subscription moves to `paused` state; Chargebee stops generating + * invoices until the subscription is resumed. + * + * @param subscriptionId - The Chargebee subscription ID to pause. + */ + async pauseSubscription(subscriptionId: string): Promise { + try { + await chargebee.subscription.pause(subscriptionId, {}).request(); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Resumes a previously paused subscription in Chargebee. + * + * @param subscriptionId - The Chargebee subscription ID to resume. + */ + async resumeSubscription(subscriptionId: string): Promise { + try { + await chargebee.subscription.resume(subscriptionId, {}).request(); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Returns a detailed price breakdown for a Chargebee invoice including + * tax and the amount excluding tax. + * + * Chargebee stores amounts as units (not cents), so no conversion needed. + * + * @param invoiceId - The Chargebee invoice ID. + * @returns {@link TInvoicePrice} with amounts in the invoice's currency unit. + */ + async getInvoicePriceDetails(invoiceId: string): Promise { + try { + const result = await chargebee.invoice.retrieve(invoiceId).request(); + const inv = result.invoice; + const taxAmount: number = inv.tax ?? 0; + const totalAmount: number = inv.total ?? 0; + + return { + currency: (inv.currency_code ?? '').toUpperCase(), + totalAmount, + taxAmount, + amountExcludingTax: totalAmount - taxAmount, + }; + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Sends a hosted payment page link for the given Chargebee invoice. + * + * Uses Chargebee's `collect_payment` with `payment_source_id` omitted, + * which results in the payment link being sent via the Chargebee notification + * configured on the site. + * + * @param invoiceId - The Chargebee invoice ID. + */ + async sendPaymentLink(invoiceId: string): Promise { + try { + // Using collect_payment without a payment_source triggers Chargebee to + // send the hosted payment page link to the customer by email + // (based on site notification settings). + await chargebee.invoice + .collect_payment(invoiceId, { + payment_source_id: undefined, + }) + .request(); + } catch (error) { + throw new Error(JSON.stringify(error)); + } + } + + /** + * Checks whether a plan-type Item exists and is active in Chargebee. + * + * @param productId - The Chargebee Item ID. + * @returns `true` if the Item is active, `false` if archived or not found. + */ + async checkProductExists(productId: string): Promise { + try { + const result = await chargebee.item.retrieve(productId).request(); + return result.item.status === 'active'; + } catch (error) { + // Chargebee throws a JSON error string with api_error_code for not-found + const message = JSON.stringify(error); + if ( + message.includes('resource_not_found') || + message.includes('not_found') + ) { + return false; + } + throw new Error(message); + } + } } diff --git a/src/providers/sdk/chargebee/type/chargebee-config.type.ts b/src/providers/sdk/chargebee/type/chargebee-config.type.ts new file mode 100644 index 0000000..5c5b16f --- /dev/null +++ b/src/providers/sdk/chargebee/type/chargebee-config.type.ts @@ -0,0 +1,38 @@ +/** + * Configuration for the Chargebee billing provider. + * + * All fields beyond `site` and `apiKey` are optional overrides — sensible + * defaults are applied when omitted so existing integrations require no changes. + */ +export interface ChargeBeeConfig { + site: string; + apiKey: string; + + /** + * The Chargebee Item Family ID that new Items (Products) are created under. + * Defaults to `'default'` which works for single-family Chargebee sites. + * Override for multi-family setups. + */ + defaultItemFamilyId?: string; + + /** + * Pricing model applied when creating ItemPrices. + * Defaults to `'flat_fee'` (single fixed recurring charge). + * Other Chargebee values: `'per_unit'`, `'tiered'`, `'volume'`, `'stairstep'`. + */ + defaultPricingModel?: string; + + /** + * When `true`, subscriptions are cancelled at the end of the current billing + * period (grace-period cancellation). When `false` (default), the + * cancellation is immediate with a prorated credit note applied. + */ + cancelAtEndOfTerm?: boolean; + + /** + * The cancel reason code sent to Chargebee when a subscription is cancelled. + * Defaults to `'customer_request'`. + * Must be one of the reason codes configured on your Chargebee site. + */ + defaultCancelReasonCode?: string; +} diff --git a/src/providers/sdk/chargebee/type/index.ts b/src/providers/sdk/chargebee/type/index.ts index e5656fc..81428ac 100644 --- a/src/providers/sdk/chargebee/type/index.ts +++ b/src/providers/sdk/chargebee/type/index.ts @@ -1,18 +1,27 @@ -import {IService, Transaction} from '../../../../types'; +import {IService, ISubscriptionService, Transaction} from '../../../../types'; import {IChargeBeeCustomer} from './customer.type'; import {IChargeBeeInvoice} from './invoice.type'; import {IChargeBeePaymentSource} from './payment-source.type'; -export interface ChargeBeeConfig { - site: string; - apiKey: string; -} export const BillingDBSourceName = 'BillingDB'; -export interface IChargeBeeService extends IService { - // No Change +/** + * Full Chargebee service interface combining one-time billing ({@link IService}) + * and recurring-subscription management ({@link ISubscriptionService}). + * + * All subscription methods map to Chargebee's Items/Item-Prices/Subscriptions API + * which is the current (v2) Chargebee data model — matching our generalised types: + * + * | Library type | Chargebee equivalent | + * |--------------------|---------------------------| + * | TProduct | Item (type: plan) | + * | TPrice | ItemPrice | + * | TSubscriptionCreate| Subscription (create_with_items) | + * | TSubscriptionUpdate| Subscription (update_for_items) | + * | TSubscriptionResult| Subscription object | + */ +export interface IChargeBeeService extends IService, ISubscriptionService { createCustomer(customerDto: IChargeBeeCustomer): Promise; - getCustomers(customerId: string): Promise; updateCustomerById( customerId: string, @@ -42,3 +51,4 @@ export interface IChargeBeeService extends IService { export * from './invoice.type'; export * from './payment-source.type'; export * from './customer.type'; +export * from './chargebee-config.type';