@mercurjs/payout-stripe-connect 2.0.0-canary.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@mercurjs/payout-stripe-connect",
3
+ "version": "2.0.0-canary.94",
4
+ "description": "Stripe Connect payment provider for Mercur",
5
+ "private": false,
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/mercurjs/mercur",
9
+ "directory": "packages/providers/payment-stripe-connect"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "author": "Medusa",
15
+ "license": "MIT",
16
+ "peerDependencies": {
17
+ "@medusajs/framework": "2.13.4"
18
+ },
19
+ "devDependencies": {
20
+ "@medusajs/framework": "2.13.4",
21
+ "@mercurjs/types": "2.0.0-canary.94"
22
+ },
23
+ "dependencies": {
24
+ "stripe": "^15.5.0"
25
+ },
26
+ "keywords": [
27
+ "medusa-plugin",
28
+ "medusa-plugin-payment",
29
+ "stripe-connect"
30
+ ]
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { ModuleProvider, Modules } from "@medusajs/framework/utils"
2
+
3
+ export default ModuleProvider(Modules.PAYMENT, {
4
+ services: [],
5
+ })
@@ -0,0 +1,267 @@
1
+ import { isPresent, MedusaError } from "@medusajs/framework/utils"
2
+ import {
3
+ CreateOnboardingInput,
4
+ CreateOnboardingResponse,
5
+ CreatePayoutAccountInput,
6
+ CreatePayoutAccountResponse,
7
+ CreatePayoutInput,
8
+ CreatePayoutResponse,
9
+ IPayoutProvider,
10
+ PayoutAccountStatus,
11
+ PayoutStatus,
12
+ PayoutWebhookActionInput,
13
+ PayoutWebhookResult,
14
+ } from "@mercurjs/types"
15
+ import { StripeConnectOptions } from "@types"
16
+ import { getSmallestUnit } from "@utils"
17
+ import Stripe from "stripe"
18
+
19
+ const DEFAULT_ACCOUNT_VALIDATION = {
20
+ detailsSubmitted: true,
21
+ chargesEnabled: true,
22
+ payoutsEnabled: true,
23
+ noOutstandingRequirements: true,
24
+ requiredCapabilities: [],
25
+ }
26
+
27
+ class StripeConnectProviderService implements IPayoutProvider {
28
+ static identifier = "stripe-connect"
29
+ protected stripe_: Stripe
30
+ protected config_: StripeConnectOptions
31
+
32
+ protected constructor(
33
+ options: StripeConnectOptions
34
+ ) {
35
+ this.config_ = options
36
+ this.stripe_ = new Stripe(options.apiKey)
37
+ }
38
+
39
+ protected normalizePayoutParameters(
40
+ extra: Record<string, unknown>
41
+ ): Partial<Stripe.TransferCreateParams> & { destination: string } {
42
+ const res = {
43
+ } as Partial<Stripe.TransferCreateParams>
44
+
45
+ res.destination = extra!.id as string
46
+ res.source_transaction = extra?.source_transaction as string | undefined
47
+ res.transfer_group = extra?.order_id as string | undefined
48
+ res.description = extra?.description as string | undefined
49
+ res.metadata = extra?.metadata as Stripe.MetadataParam | undefined
50
+
51
+
52
+ return res as Partial<Stripe.TransferCreateParams> & { destination: string }
53
+ }
54
+
55
+ private getWebhookResultFromAccount_(
56
+ account: Stripe.Account
57
+ ): PayoutWebhookResult {
58
+ const validation = {
59
+ ...DEFAULT_ACCOUNT_VALIDATION,
60
+ ...this.config_.accountValidation,
61
+ }
62
+ const requirements = account.requirements
63
+ const disabledReason = requirements?.disabled_reason
64
+ const hasOutstandingRequirements = Boolean(
65
+ requirements?.currently_due?.length ||
66
+ requirements?.past_due?.length ||
67
+ requirements?.pending_verification?.length
68
+ )
69
+ const requiredCapabilities = validation.requiredCapabilities ?? []
70
+ const hasInactiveCapabilities = requiredCapabilities.some((capability) => {
71
+ return account.capabilities?.[capability as keyof Stripe.Account.Capabilities] !== "active"
72
+ })
73
+
74
+ if (disabledReason?.startsWith("rejected.")) {
75
+ return {
76
+ action: "account.rejected",
77
+ data: {
78
+ id: account.id,
79
+ },
80
+ }
81
+ }
82
+
83
+ if (
84
+ (!validation.detailsSubmitted || account.details_submitted) &&
85
+ (!validation.chargesEnabled || account.charges_enabled) &&
86
+ (!validation.payoutsEnabled || account.payouts_enabled) &&
87
+ (!validation.noOutstandingRequirements || !hasOutstandingRequirements) &&
88
+ !hasInactiveCapabilities
89
+ ) {
90
+ return {
91
+ action: "account.activated",
92
+ data: {
93
+ id: account.id,
94
+ },
95
+ }
96
+ }
97
+
98
+ if (disabledReason) {
99
+ return {
100
+ action: "account.restricted",
101
+ data: {
102
+ id: account.id,
103
+ },
104
+ }
105
+ }
106
+
107
+ return {
108
+ action: "account.restricted",
109
+ data: {
110
+ id: account.id,
111
+ },
112
+ }
113
+ }
114
+
115
+ private getWebhookResultFromPayout_(
116
+ payout: Stripe.Payout
117
+ ): PayoutWebhookResult {
118
+ const payoutId = payout.metadata?.payout_id || payout.id
119
+
120
+ switch (payout.status) {
121
+ case "pending":
122
+ case "in_transit":
123
+ return {
124
+ action: "payout.processing",
125
+ data: {
126
+ id: payoutId,
127
+ },
128
+ }
129
+ case "paid":
130
+ return {
131
+ action: "payout.paid",
132
+ data: {
133
+ id: payoutId,
134
+ },
135
+ }
136
+ case "failed":
137
+ return {
138
+ action: "payout.failed",
139
+ data: {
140
+ id: payoutId,
141
+ },
142
+ }
143
+ case "canceled":
144
+ return {
145
+ action: "payout.canceled",
146
+ data: {
147
+ id: payoutId,
148
+ },
149
+ }
150
+ default:
151
+ return {
152
+ action: "not_supported",
153
+ }
154
+ }
155
+ }
156
+
157
+ async createPayoutAccount(
158
+ input: CreatePayoutAccountInput
159
+ ): Promise<CreatePayoutAccountResponse> {
160
+ const { data } = input;
161
+ const country = data?.country as string
162
+
163
+ if (!isPresent(country)) {
164
+ throw new MedusaError(
165
+ MedusaError.Types.INVALID_DATA,
166
+ `"country" is required`
167
+ );
168
+ }
169
+
170
+ const response = await this.stripe_.accounts.create({
171
+ type: 'express',
172
+ country,
173
+ }, {
174
+ idempotencyKey: input.context?.idempotency_key
175
+ })
176
+
177
+ return {
178
+ id: response.id,
179
+ status: PayoutAccountStatus.PENDING,
180
+ data: response as unknown as Record<string, unknown>
181
+ }
182
+ }
183
+
184
+ async createPayout(input: CreatePayoutInput): Promise<CreatePayoutResponse> {
185
+ const normalizedInput = this.normalizePayoutParameters(input.data!)
186
+ const transfer = await this.stripe_.transfers.create(
187
+ {
188
+ currency: input.currency_code,
189
+ amount: getSmallestUnit(input.amount, input.currency_code),
190
+ ...normalizedInput,
191
+ },
192
+ { idempotencyKey: input.context?.idempotency_key }
193
+ );
194
+
195
+
196
+ return {
197
+ data: transfer as unknown as Record<string, unknown>,
198
+ status: PayoutStatus.PENDING
199
+ }
200
+ }
201
+
202
+ async createOnboarding(
203
+ input: CreateOnboardingInput
204
+ ): Promise<CreateOnboardingResponse> {
205
+ const id = input?.data?.id as string
206
+ if (!isPresent(id)) {
207
+ throw new MedusaError(
208
+ MedusaError.Types.INVALID_DATA,
209
+ `'id' is required`
210
+ );
211
+ }
212
+
213
+ if (!isPresent(input.data?.refresh_url)) {
214
+ throw new MedusaError(
215
+ MedusaError.Types.INVALID_DATA,
216
+ `'refresh_url' is required`
217
+ );
218
+ }
219
+
220
+ if (!isPresent(input.data?.return_url)) {
221
+ throw new MedusaError(
222
+ MedusaError.Types.INVALID_DATA,
223
+ `'return_url' is required`
224
+ );
225
+ }
226
+
227
+ const accountLink = await this.stripe_.accountLinks.create({
228
+ account: id,
229
+ refresh_url: input.data?.refresh_url as string,
230
+ return_url: input.data?.return_url as string,
231
+ type: "account_onboarding",
232
+ }, {
233
+ idempotencyKey: input.context?.idempotency_key
234
+ });
235
+
236
+ return {
237
+ data: accountLink as unknown as Record<string, unknown>,
238
+ };
239
+ }
240
+
241
+ async getWebhookActionAndData(
242
+ payload: PayoutWebhookActionInput
243
+ ): Promise<PayoutWebhookResult> {
244
+ const signature = payload.headers["stripe-signature"] as string
245
+
246
+ const event = this.stripe_.webhooks.constructEvent(
247
+ payload.rawData as string | Buffer,
248
+ signature,
249
+ this.config_.webhookSecret
250
+ )
251
+
252
+ switch (event.type) {
253
+ case "account.updated":
254
+ return this.getWebhookResultFromAccount_(event.data.object as Stripe.Account)
255
+ case "payout.created":
256
+ case "payout.updated":
257
+ case "payout.paid":
258
+ case "payout.failed":
259
+ case "payout.canceled":
260
+ return this.getWebhookResultFromPayout_(event.data.object as Stripe.Payout)
261
+ default:
262
+ return { action: "not_supported" }
263
+ }
264
+ }
265
+ }
266
+
267
+ export default StripeConnectProviderService
@@ -0,0 +1,37 @@
1
+ export interface StripeConnectAccountValidationOptions {
2
+ /**
3
+ * Require Stripe to mark onboarding details as submitted
4
+ */
5
+ detailsSubmitted?: boolean
6
+ /**
7
+ * Require the account to be enabled for charges
8
+ */
9
+ chargesEnabled?: boolean
10
+ /**
11
+ * Require the account to be enabled for payouts
12
+ */
13
+ payoutsEnabled?: boolean
14
+ /**
15
+ * Treat pending Stripe requirements as a restricted account
16
+ */
17
+ noOutstandingRequirements?: boolean
18
+ /**
19
+ * Require specific Stripe capabilities to be active
20
+ */
21
+ requiredCapabilities?: string[]
22
+ }
23
+
24
+ export interface StripeConnectOptions {
25
+ /**
26
+ * The API key for the Stripe Connect account
27
+ */
28
+ apiKey: string
29
+ /**
30
+ * The webhook secret used to verify webhooks
31
+ */
32
+ webhookSecret: string
33
+ /**
34
+ * Controls how connected accounts are validated from Stripe webhook data
35
+ */
36
+ accountValidation?: StripeConnectAccountValidationOptions
37
+ }
@@ -0,0 +1,79 @@
1
+ import { BigNumberInput } from '@medusajs/framework/types'
2
+ import { BigNumber, MathBN } from '@medusajs/framework/utils'
3
+
4
+ function getCurrencyMultiplier(currency) {
5
+ const currencyMultipliers = {
6
+ 0: [
7
+ 'BIF',
8
+ 'CLP',
9
+ 'DJF',
10
+ 'GNF',
11
+ 'JPY',
12
+ 'KMF',
13
+ 'KRW',
14
+ 'MGA',
15
+ 'PYG',
16
+ 'RWF',
17
+ 'UGX',
18
+ 'VND',
19
+ 'VUV',
20
+ 'XAF',
21
+ 'XOF',
22
+ 'XPF'
23
+ ],
24
+ 3: ['BHD', 'IQD', 'JOD', 'KWD', 'OMR', 'TND']
25
+ }
26
+
27
+ currency = currency.toUpperCase()
28
+ let power = 2
29
+ for (const [key, value] of Object.entries(currencyMultipliers)) {
30
+ if (value.includes(currency)) {
31
+ power = parseInt(key, 10)
32
+ break
33
+ }
34
+ }
35
+ return Math.pow(10, power)
36
+ }
37
+
38
+ /**
39
+ * Converts an amount to the format required by Stripe based on currency.
40
+ * https://docs.stripe.com/currencies
41
+ * @param {BigNumberInput} amount - The amount to be converted.
42
+ * @param {string} currency - The currency code (e.g., 'USD', 'JOD').
43
+ * @returns {number} - The converted amount in the smallest currency unit.
44
+ */
45
+ export function getSmallestUnit(
46
+ amount: BigNumberInput,
47
+ currency: string
48
+ ): number {
49
+ const multiplier = getCurrencyMultiplier(currency)
50
+
51
+ const amount_ =
52
+ Math.round(new BigNumber(MathBN.mult(amount, multiplier)).numeric) /
53
+ multiplier
54
+
55
+ const smallestAmount = new BigNumber(MathBN.mult(amount_, multiplier))
56
+
57
+ let numeric = smallestAmount.numeric
58
+ // Check if the currency requires rounding to the nearest ten
59
+ if (multiplier === 1e3) {
60
+ numeric = Math.ceil(numeric / 10) * 10
61
+ }
62
+
63
+ return parseInt(numeric.toString().split('.').shift()!, 10)
64
+ }
65
+
66
+ /**
67
+ * Converts an amount from the smallest currency unit to the standard unit based on currency.
68
+ * @param {BigNumberInput} amount - The amount in the smallest currency unit.
69
+ * @param {string} currency - The currency code (e.g., 'USD', 'JOD').
70
+ * @returns {number} - The converted amount in the standard currency unit.
71
+ */
72
+ export function getAmountFromSmallestUnit(
73
+ amount: BigNumberInput,
74
+ currency: string
75
+ ): number {
76
+ const multiplier = getCurrencyMultiplier(currency)
77
+ const standardAmount = new BigNumber(MathBN.div(amount, multiplier))
78
+ return standardAmount.numeric
79
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ES2021"],
4
+ "target": "ES2021",
5
+ "outDir": "./dist",
6
+ "esModuleInterop": true,
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "noUnusedLocals": true,
10
+ "module": "Node16",
11
+ "moduleResolution": "Node16",
12
+ "emitDecoratorMetadata": true,
13
+ "experimentalDecorators": true,
14
+ "sourceMap": true,
15
+ "noImplicitReturns": true,
16
+ "resolveJsonModule": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "strictNullChecks": true,
19
+ "strictFunctionTypes": true,
20
+ "noImplicitThis": true,
21
+ "allowJs": true,
22
+ "skipLibCheck": true,
23
+ "incremental": false,
24
+ "paths": {
25
+ "@models": ["./src/models"],
26
+ "@services": ["./src/services"],
27
+ "@repositories": ["./src/repositories"],
28
+ "@types": ["./src/types"],
29
+ "@utils": ["./src/utils"]
30
+ }
31
+ },
32
+ "include": ["./src"],
33
+ "exclude": ["./dist", "./node_modules"]
34
+ }