@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 +31 -0
- package/src/index.ts +5 -0
- package/src/services/stripe-connect.ts +267 -0
- package/src/types/index.ts +37 -0
- package/src/utils/index.ts +79 -0
- package/tsconfig.json +34 -0
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,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
|
+
}
|