chore(): Modules providers reorganization (#7234)

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Adrien de Peretti
2024-05-05 10:08:07 +02:00
committed by GitHub
co-authored by Riqwan Thamir
parent 2f7b53488d
commit 93f6e60c17
47 changed files with 14 additions and 9 deletions
@@ -0,0 +1,38 @@
{
"name": "@medusajs/file-local-next",
"version": "0.0.2",
"description": "Local filesystem file storage for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/file-local"
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
"test": "jest --passWithNoTests src",
"build": "rimraf dist && tsc -p ./tsconfig.json",
"watch": "tsc --watch"
},
"devDependencies": {
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@medusajs/utils": "^1.11.7"
},
"keywords": [
"medusa-plugin",
"medusa-plugin-file"
]
}
@@ -0,0 +1,10 @@
import { ModuleProviderExports } from "@medusajs/types"
import { LocalFileService } from "./services/local-file"
const services = [LocalFileService]
const providerExport: ModuleProviderExports = {
services,
}
export default providerExport
@@ -0,0 +1,100 @@
import { FileTypes, LocalFileServiceOptions } from "@medusajs/types"
import { AbstractFileProviderService, MedusaError } from "@medusajs/utils"
import fs from "fs/promises"
import path from "path"
export class LocalFileService extends AbstractFileProviderService {
static identifier = "localfs"
protected uploadDir_: string
protected backendUrl_: string
constructor(_, options: LocalFileServiceOptions) {
super()
this.uploadDir_ = options?.upload_dir || "uploads"
this.backendUrl_ = options?.backend_url || "http://localhost:9000"
}
async upload(
file: FileTypes.ProviderUploadFileDTO
): Promise<FileTypes.ProviderFileResultDTO> {
if (!file) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `No file provided`)
}
if (!file.filename) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
)
}
const parsedFilename = path.parse(file.filename)
if (parsedFilename.dir) {
this.ensureDirExists(parsedFilename.dir)
}
const fileKey = path.join(
parsedFilename.dir,
`${Date.now()}-${parsedFilename.base}`
)
const filePath = this.getUploadFilePath(fileKey)
const fileUrl = this.getUploadFileUrl(fileKey)
const content = Buffer.from(file.content, "binary")
await fs.writeFile(filePath, content)
return {
key: fileKey,
url: fileUrl,
}
}
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
const filePath = this.getUploadFilePath(file.fileKey)
try {
await fs.access(filePath, fs.constants.F_OK)
await fs.unlink(filePath)
} catch (e) {
// The file does not exist, so it's a noop.
}
return
}
async getPresignedDownloadUrl(
fileData: FileTypes.ProviderGetFileDTO
): Promise<string> {
try {
await fs.access(
this.getUploadFilePath(fileData.fileKey),
fs.constants.F_OK
)
} catch {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`File with key ${fileData.fileKey} not found`
)
}
return this.getUploadFileUrl(fileData.fileKey)
}
private getUploadFilePath = (fileKey: string) => {
return path.join(this.uploadDir_, fileKey)
}
private getUploadFileUrl = (fileKey: string) => {
return path.join(this.backendUrl_, this.getUploadFilePath(fileKey))
}
private async ensureDirExists(dirPath: string) {
const relativePath = path.join(this.uploadDir_, dirPath)
try {
await fs.access(relativePath, fs.constants.F_OK)
} catch (e) {
await fs.mkdir(relativePath, { recursive: true })
}
}
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
},
"include": ["src"],
"exclude": [
"dist",
"build",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules",
".eslintrc.js"
]
}
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,79 @@
import fs from "fs/promises"
import axios from "axios"
import { S3FileService } from "../../src/services/s3-file"
jest.setTimeout(100000)
// Note: This test hits the S3 service, and it is mainly meant to be run manually after setting all the envvars below.
// We can also set up some test buckets in our pipeline to run this test, but it is not really that important to do so for now.
describe.skip("S3 File Plugin", () => {
let s3Service: S3FileService
let fixtureImagePath: string
beforeAll(() => {
fixtureImagePath =
process.cwd() + "/integration-tests/__fixtures__/catphoto.jpg"
s3Service = new S3FileService(
{
logger: console as any,
},
{
endpoint: process.env.S3_TEST_ENDPOINT ?? "",
file_url: process.env.S3_TEST_FILE_URL ?? "",
access_key_id: process.env.S3_TEST_ACCESS_KEY_ID ?? "",
secret_access_key: process.env.S3_TEST_SECRET_ACCESS_KEY ?? "",
region: process.env.S3_TEST_REGION ?? "",
bucket: process.env.S3_TEST_BUCKET ?? "",
prefix: "tests/",
additional_client_config: process.env.S3_TEST_ENDPOINT?.includes(
"localhost"
)
? {
sslEnabled: false,
s3ForcePathStyle: true,
}
: {},
}
)
})
it("uploads, reads, and then deletes a file successfully", async () => {
const fileContent = await fs.readFile(fixtureImagePath)
const fixtureAsBinary = fileContent.toString("binary")
const resp = await s3Service.upload({
filename: "catphoto.jpg",
mimeType: "image/jpeg",
content: fixtureAsBinary,
})
expect(resp).toEqual({
key: expect.stringMatching(/tests\/catphoto.*\.jpg/),
url: expect.stringMatching(/https:\/\/.*\.jpg/),
})
const signedUrl = await s3Service.getPresignedDownloadUrl({
fileKey: resp.key,
})
const signedUrlFile = Buffer.from(
await axios
.get(signedUrl, { responseType: "arraybuffer" })
.then((r) => r.data)
)
expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary)
await s3Service.delete({ fileKey: resp.key })
// TODO: Currently the presignedURL will be returned even if the file doesn't exist. Should we check for existence first?
const deletedFileUrl = await s3Service.getPresignedDownloadUrl({
fileKey: resp.key,
})
const { response } = await axios
.get(deletedFileUrl, { responseType: "arraybuffer" })
.catch((e) => e)
expect(response.status).toEqual(404)
})
})
@@ -0,0 +1,16 @@
module.exports = {
globals: {
"ts-jest": {
tsconfig: "tsconfig.spec.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleNameMapper: {
"^axios$": "axios/dist/node/axios.cjs",
},
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
}
@@ -0,0 +1,43 @@
{
"name": "@medusajs/file-s3",
"version": "0.0.2",
"description": "S3 protocol file storage for Medusa. Supports any S3-compatible storage provider",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/file-local"
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
"test": "jest --passWithNoTests src",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts",
"build": "rimraf dist && tsc -p ./tsconfig.json",
"watch": "tsc --watch"
},
"devDependencies": {
"axios": "^1.6.8",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.556.0",
"@aws-sdk/s3-request-presigner": "^3.556.0",
"@medusajs/utils": "^1.11.7",
"ulid": "^2.3.0"
},
"keywords": [
"medusa-plugin",
"medusa-plugin-s3"
]
}
@@ -0,0 +1,10 @@
import { ModuleProviderExports } from "@medusajs/types"
import { S3FileService } from "./services/s3-file"
const services = [S3FileService]
const providerExport: ModuleProviderExports = {
services,
}
export default providerExport
@@ -0,0 +1,154 @@
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
S3ClientConfigType,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { FileTypes, Logger, S3FileServiceOptions } from "@medusajs/types"
import { AbstractFileProviderService, MedusaError } from "@medusajs/utils"
import path from "path"
import { ulid } from "ulid"
type InjectedDependencies = {
logger: Logger
}
interface S3FileServiceConfig {
// TODO: We probably don't need this as either the service should return it or we should be able to calculate it.
fileUrl: string
accessKeyId: string
secretAccessKey: string
region: string
bucket: string
prefix?: string
endpoint?: string
cacheControl?: string
downloadFileDuration?: number
additionalClientConfig?: Record<string, any>
}
// FUTURE: At one point we will probably need to support authenticating with IAM roles instead.
export class S3FileService extends AbstractFileProviderService {
static identifier = "s3"
protected config_: S3FileServiceConfig
protected logger_: Logger
protected client_: S3Client
constructor({ logger }: InjectedDependencies, options: S3FileServiceOptions) {
super()
this.config_ = {
fileUrl: options.file_url,
accessKeyId: options.access_key_id,
secretAccessKey: options.secret_access_key,
region: options.region,
bucket: options.bucket,
prefix: options.prefix ?? "",
endpoint: options.endpoint,
cacheControl: options.cache_control ?? "public, max-age=31536000",
downloadFileDuration: options.download_file_duration ?? 60 * 60,
additionalClientConfig: options.additional_client_config ?? {},
}
this.logger_ = logger
this.client_ = this.getClient()
}
protected getClient() {
const config: S3ClientConfigType = {
credentials: {
accessKeyId: this.config_.accessKeyId,
secretAccessKey: this.config_.secretAccessKey,
},
region: this.config_.region,
endpoint: this.config_.endpoint,
...this.config_.additionalClientConfig,
}
return new S3Client(config)
}
async upload(
file: FileTypes.ProviderUploadFileDTO
): Promise<FileTypes.ProviderFileResultDTO> {
if (!file) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `No file provided`)
}
if (!file.filename) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`No filename provided`
)
}
const parsedFilename = path.parse(file.filename)
// TODO: Allow passing a full path for storage per request, not as a global config.
const fileKey = `${this.config_.prefix}${parsedFilename.name}-${ulid()}${
parsedFilename.ext
}`
const content = Buffer.from(file.content, "binary")
const command = new PutObjectCommand({
// TODO: Add support for private files
// We probably also want to support a separate bucket altogether for private files
// protected private_bucket_: string
// protected private_access_key_id_: string
// protected private_secret_access_key_: string
// ACL: options.acl ?? (options.isProtected ? "private" : "public-read"),
Bucket: this.config_.bucket,
Body: content,
Key: fileKey,
ContentType: file.mimeType,
CacheControl: this.config_.cacheControl,
// Note: We could potentially set the content disposition when uploading,
// but storing the original filename as metadata should suffice.
Metadata: {
"x-amz-meta-original-filename": file.filename,
},
})
try {
await this.client_.send(command)
} catch (e) {
this.logger_.error(e)
throw e
}
return {
url: `${this.config_.fileUrl}/${fileKey}`,
key: fileKey,
}
}
async delete(file: FileTypes.ProviderDeleteFileDTO): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.config_.bucket,
Key: file.fileKey,
})
try {
await this.client_.send(command)
} catch (e) {
// TODO: Rethrow depending on the error (eg. a file not found error is fine, but a failed request should be rethrown)
this.logger_.error(e)
}
}
async getPresignedDownloadUrl(
fileData: FileTypes.ProviderGetFileDTO
): Promise<string> {
// TODO: Allow passing content disposition when getting a presigned URL
const command = new GetObjectCommand({
Bucket: this.config_.bucket,
Key: `${fileData.fileKey}`,
})
return await getSignedUrl(this.client_, command, {
expiresIn: this.config_.downloadFileDuration,
})
}
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
},
"include": ["src"],
"exclude": [
"dist",
"build",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules",
".eslintrc.js"
]
}
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,4 @@
dist
node_modules
.DS_store
yarn.lock
@@ -0,0 +1,10 @@
# @medusajs/fulfillment-manual
## 0.0.2
### Patch Changes
- [#6700](https://github.com/medusajs/medusa/pull/6700) [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8) Thanks [@olivermrbl](https://github.com/olivermrbl)! - chore: Version all modules to allow for initial testing
- Updated dependencies [[`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`56cbf88115`](https://github.com/medusajs/medusa/commit/56cbf88115994adea7037c3f2814f0c96af3cfc0), [`36a61658f9`](https://github.com/medusajs/medusa/commit/36a61658f969a7b19c84a1e621ad1464927cafb1), [`c319edb8e0`](https://github.com/medusajs/medusa/commit/c319edb8e0ecd13d086652147667916e5abab2d8), [`0b9fcb6324`](https://github.com/medusajs/medusa/commit/0b9fcb6324eee9f2556c7e6317775fae93b12a47), [`b3d826497b`](https://github.com/medusajs/medusa/commit/b3d826497b3dae5e1b26b7924706c24fd5e87ca5), [`a86c87fe14`](https://github.com/medusajs/medusa/commit/a86c87fe1442afce9285e39255914e01012b4449), [`640eccd5dd`](https://github.com/medusajs/medusa/commit/640eccd5ddbb163e0f987ce6c772f1129c2e2632), [`8ea37d03c9`](https://github.com/medusajs/medusa/commit/8ea37d03c914a5004a3e42770668b2d1f7f8f564), [`339a946f38`](https://github.com/medusajs/medusa/commit/339a946f389033c21e05338f9dbf07d88e140533), [`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`8dad2b51a2`](https://github.com/medusajs/medusa/commit/8dad2b51a26c4c3c14a6c95f70424c8bef2ad63e), [`a6d7070dd6`](https://github.com/medusajs/medusa/commit/a6d7070dd669c21ea19d70434d42c2f8167dc309), [`168f02f138`](https://github.com/medusajs/medusa/commit/168f02f138ad101e1013f2c8c3f8dc19de12accf), [`f5c2256286`](https://github.com/medusajs/medusa/commit/f5c22562867f412040f8bc6c55ab5de3a3735e62), [`000eb61e33`](https://github.com/medusajs/medusa/commit/000eb61e33e0302db95ee6ad1656ea9b430ed471), [`62a7bcc30c`](https://github.com/medusajs/medusa/commit/62a7bcc30cbc7b234b2b51d7858439951a84edeb), [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8), [`6500f18b9b`](https://github.com/medusajs/medusa/commit/6500f18b9b80c5c9c473489e7e740d55dca74303), [`ce39b9b66e`](https://github.com/medusajs/medusa/commit/ce39b9b66e8c277ec0691ea6d0a950003be09cc1), [`a6a4b3f01a`](https://github.com/medusajs/medusa/commit/a6a4b3f01a6d2bd97b1580c59134279a1b033a5d), [`56b0b45304`](https://github.com/medusajs/medusa/commit/56b0b4530401a6ec5aa155874d371e45bb388fe2), [`cc1b66842c`](https://github.com/medusajs/medusa/commit/cc1b66842cbb37c6eab84e2d8b74844c214f38d7), [`e85463b2a7`](https://github.com/medusajs/medusa/commit/e85463b2a717751de2e21c39a4c745449b31affe)]:
- @medusajs/utils@1.11.7
@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsconfig: "tsconfig.spec.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
}
@@ -0,0 +1,40 @@
{
"name": "@medusajs/fulfillment-manual",
"version": "0.0.2",
"description": "Manual fulfillment for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/fulfillment-manual"
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
"test": "jest --passWithNoTests src",
"build": "rimraf dist && tsc -p ./tsconfig.json",
"watch": "tsc --watch"
},
"devDependencies": {
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@medusajs/utils": "^1.11.7",
"body-parser": "^1.19.0",
"express": "^4.17.1"
},
"keywords": [
"medusa-plugin",
"medusa-plugin-fulfillment"
]
}
@@ -0,0 +1,10 @@
import { ModuleProviderExports } from "@medusajs/types"
import { ManualFulfillmentService } from "./services/manual-fulfillment"
const services = [ManualFulfillmentService]
const providerExport: ModuleProviderExports = {
services,
}
export default providerExport
@@ -0,0 +1,44 @@
import { AbstractFulfillmentProviderService } from "@medusajs/utils"
// TODO rework type and DTO's
export class ManualFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "manual"
constructor() {
super()
}
async getFulfillmentOptions(): Promise<Record<string, unknown>[]> {
return [
{
id: "manual-fulfillment",
},
{
id: "manual-fulfillment-return",
is_return: true,
},
]
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
context: Record<string, unknown>
): Promise<any> {
return data
}
async validateOption(data: Record<string, unknown>): Promise<boolean> {
return true
}
async createFulfillment(): Promise<Record<string, unknown>> {
// No data is being sent anywhere
return {}
}
async cancelFulfillment(fulfillment: Record<string, unknown>): Promise<any> {
return {}
}
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
},
"include": ["src"],
"exclude": [
"dist",
"build",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules",
".eslintrc.js"
]
}
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,4 @@
dist
node_modules
.DS_store
yarn.lock
@@ -0,0 +1,10 @@
# @medusajs/payment-stripe
## 0.0.2
### Patch Changes
- [#6700](https://github.com/medusajs/medusa/pull/6700) [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8) Thanks [@olivermrbl](https://github.com/olivermrbl)! - chore: Version all modules to allow for initial testing
- Updated dependencies [[`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`56cbf88115`](https://github.com/medusajs/medusa/commit/56cbf88115994adea7037c3f2814f0c96af3cfc0), [`36a61658f9`](https://github.com/medusajs/medusa/commit/36a61658f969a7b19c84a1e621ad1464927cafb1), [`c319edb8e0`](https://github.com/medusajs/medusa/commit/c319edb8e0ecd13d086652147667916e5abab2d8), [`0b9fcb6324`](https://github.com/medusajs/medusa/commit/0b9fcb6324eee9f2556c7e6317775fae93b12a47), [`b3d826497b`](https://github.com/medusajs/medusa/commit/b3d826497b3dae5e1b26b7924706c24fd5e87ca5), [`a86c87fe14`](https://github.com/medusajs/medusa/commit/a86c87fe1442afce9285e39255914e01012b4449), [`640eccd5dd`](https://github.com/medusajs/medusa/commit/640eccd5ddbb163e0f987ce6c772f1129c2e2632), [`8ea37d03c9`](https://github.com/medusajs/medusa/commit/8ea37d03c914a5004a3e42770668b2d1f7f8f564), [`339a946f38`](https://github.com/medusajs/medusa/commit/339a946f389033c21e05338f9dbf07d88e140533), [`9288f53327`](https://github.com/medusajs/medusa/commit/9288f53327b8ce617af92ed8d14d9459cbfeb13c), [`8dad2b51a2`](https://github.com/medusajs/medusa/commit/8dad2b51a26c4c3c14a6c95f70424c8bef2ad63e), [`a6d7070dd6`](https://github.com/medusajs/medusa/commit/a6d7070dd669c21ea19d70434d42c2f8167dc309), [`168f02f138`](https://github.com/medusajs/medusa/commit/168f02f138ad101e1013f2c8c3f8dc19de12accf), [`f5c2256286`](https://github.com/medusajs/medusa/commit/f5c22562867f412040f8bc6c55ab5de3a3735e62), [`000eb61e33`](https://github.com/medusajs/medusa/commit/000eb61e33e0302db95ee6ad1656ea9b430ed471), [`62a7bcc30c`](https://github.com/medusajs/medusa/commit/62a7bcc30cbc7b234b2b51d7858439951a84edeb), [`8f8a4f9b13`](https://github.com/medusajs/medusa/commit/8f8a4f9b1353087d98f6cc75346d43a7f49901a8), [`6500f18b9b`](https://github.com/medusajs/medusa/commit/6500f18b9b80c5c9c473489e7e740d55dca74303), [`ce39b9b66e`](https://github.com/medusajs/medusa/commit/ce39b9b66e8c277ec0691ea6d0a950003be09cc1), [`a6a4b3f01a`](https://github.com/medusajs/medusa/commit/a6a4b3f01a6d2bd97b1580c59134279a1b033a5d), [`56b0b45304`](https://github.com/medusajs/medusa/commit/56b0b4530401a6ec5aa155874d371e45bb388fe2), [`cc1b66842c`](https://github.com/medusajs/medusa/commit/cc1b66842cbb37c6eab84e2d8b74844c214f38d7), [`e85463b2a7`](https://github.com/medusajs/medusa/commit/e85463b2a717751de2e21c39a4c745449b31affe)]:
- @medusajs/utils@1.11.7
@@ -0,0 +1,13 @@
module.exports = {
globals: {
"ts-jest": {
tsconfig: "tsconfig.spec.json",
isolatedModules: false,
},
},
transform: {
"^.+\\.[jt]s?$": "ts-jest",
},
testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
}
@@ -0,0 +1,48 @@
{
"name": "@medusajs/payment-stripe",
"version": "0.0.2",
"description": "Stripe payment provider for Medusa",
"main": "dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/payment-stripe"
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"author": "Medusa",
"license": "MIT",
"scripts": {
"prepublishOnly": "cross-env NODE_ENV=production tsc --build",
"test": "jest --passWithNoTests src",
"build": "rimraf dist && tsc -p ./tsconfig.json",
"watch": "tsc --watch"
},
"devDependencies": {
"@medusajs/medusa": "^1.20.3",
"@types/stripe": "^8.0.417",
"awilix": "^8.0.1",
"cross-env": "^5.2.1",
"jest": "^25.5.4",
"rimraf": "^5.0.1",
"typescript": "^4.9.5"
},
"peerDependencies": {
"@medusajs/medusa": "^1.12.0"
},
"dependencies": {
"@medusajs/utils": "^1.11.7",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"stripe": "latest"
},
"gitHead": "81a7ff73d012fda722f6e9ef0bd9ba0232d37808",
"keywords": [
"medusa-plugin",
"medusa-plugin-payment"
]
}
@@ -0,0 +1,381 @@
import { EOL } from "os"
import Stripe from "stripe"
import {
MedusaContainer,
PaymentProviderError,
PaymentProviderSessionResponse,
PaymentSessionStatus,
ProviderWebhookPayload,
UpdatePaymentProviderSession,
WebhookActionResult,
} from "@medusajs/types"
import {
AbstractPaymentProvider,
BigNumber,
MedusaError,
PaymentActions,
isPaymentProviderError,
} from "@medusajs/utils"
import { isDefined } from "medusa-core-utils"
import { CreatePaymentProviderSession } from "@medusajs/types"
import {
ErrorCodes,
ErrorIntentStatus,
PaymentIntentOptions,
StripeCredentials,
StripeOptions,
} from "../types"
abstract class StripeBase extends AbstractPaymentProvider<StripeCredentials> {
protected readonly options_: StripeOptions
protected stripe_: Stripe
protected container_: MedusaContainer
protected constructor(container: MedusaContainer, options: StripeOptions) {
// @ts-ignore
super(...arguments)
this.container_ = container
this.options_ = options
this.stripe_ = this.init()
}
protected init() {
this.validateOptions(this.config)
return new Stripe(this.config.apiKey)
}
abstract get paymentIntentOptions(): PaymentIntentOptions
private validateOptions(options: StripeCredentials): void {
if (!isDefined(options.apiKey)) {
throw new Error("Required option `apiKey` is missing in Stripe plugin")
}
}
get options(): StripeOptions {
return this.options_
}
getPaymentIntentOptions(): PaymentIntentOptions {
const options: PaymentIntentOptions = {}
if (this?.paymentIntentOptions?.capture_method) {
options.capture_method = this.paymentIntentOptions.capture_method
}
if (this?.paymentIntentOptions?.setup_future_usage) {
options.setup_future_usage = this.paymentIntentOptions.setup_future_usage
}
if (this?.paymentIntentOptions?.payment_method_types) {
options.payment_method_types =
this.paymentIntentOptions.payment_method_types
}
return options
}
async getPaymentStatus(
paymentSessionData: Record<string, unknown>
): Promise<PaymentSessionStatus> {
const id = paymentSessionData.id as string
const paymentIntent = await this.stripe_.paymentIntents.retrieve(id)
switch (paymentIntent.status) {
case "requires_payment_method":
case "requires_confirmation":
case "processing":
return PaymentSessionStatus.PENDING
case "requires_action":
return PaymentSessionStatus.REQUIRES_MORE
case "canceled":
return PaymentSessionStatus.CANCELED
case "requires_capture":
case "succeeded":
return PaymentSessionStatus.AUTHORIZED
default:
return PaymentSessionStatus.PENDING
}
}
async initiatePayment(
input: CreatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse> {
const intentRequestData = this.getPaymentIntentOptions()
const { email, extra, resource_id, customer } = input.context
const { currency_code, amount } = input
const description = (extra?.payment_description ??
this.options_?.payment_description) as string
const intentRequest: Stripe.PaymentIntentCreateParams = {
description,
amount: Math.round(new BigNumber(amount).numeric),
currency: currency_code,
metadata: { resource_id: resource_id ?? "Medusa Payment" },
capture_method: this.options_.capture ? "automatic" : "manual",
...intentRequestData,
}
if (this.options_?.automatic_payment_methods) {
intentRequest.automatic_payment_methods = { enabled: true }
}
if (customer?.metadata?.stripe_id) {
intentRequest.customer = customer.metadata.stripe_id as string
} else {
let stripeCustomer
try {
stripeCustomer = await this.stripe_.customers.create({
email,
})
} catch (e) {
return this.buildError(
"An error occurred in initiatePayment when creating a Stripe customer",
e
)
}
intentRequest.customer = stripeCustomer.id
}
let sessionData
try {
sessionData = (await this.stripe_.paymentIntents.create(
intentRequest
)) as unknown as Record<string, unknown>
} catch (e) {
return this.buildError(
"An error occurred in InitiatePayment during the creation of the stripe payment intent",
e
)
}
return {
data: sessionData,
// TODO: REVISIT
// update_requests: customer?.metadata?.stripe_id
// ? undefined
// : {
// customer_metadata: {
// stripe_id: intentRequest.customer,
// },
// },
}
}
async authorizePayment(
paymentSessionData: Record<string, unknown>,
context: Record<string, unknown>
): Promise<
| PaymentProviderError
| {
status: PaymentSessionStatus
data: PaymentProviderSessionResponse["data"]
}
> {
const status = await this.getPaymentStatus(paymentSessionData)
return { data: paymentSessionData, status }
}
async cancelPayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
try {
const id = paymentSessionData.id as string
return (await this.stripe_.paymentIntents.cancel(
id
)) as unknown as PaymentProviderSessionResponse["data"]
} catch (error) {
if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) {
return error.payment_intent
}
return this.buildError("An error occurred in cancelPayment", error)
}
}
async capturePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
const id = paymentSessionData.id as string
try {
const intent = await this.stripe_.paymentIntents.capture(id)
return intent as unknown as PaymentProviderSessionResponse["data"]
} catch (error) {
if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) {
if (error.payment_intent?.status === ErrorIntentStatus.SUCCEEDED) {
return error.payment_intent
}
}
return this.buildError("An error occurred in capturePayment", error)
}
}
async deletePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
return await this.cancelPayment(paymentSessionData)
}
async refundPayment(
paymentSessionData: Record<string, unknown>,
refundAmount: number
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
const id = paymentSessionData.id as string
try {
await this.stripe_.refunds.create({
amount: Math.round(refundAmount),
payment_intent: id as string,
})
} catch (e) {
return this.buildError("An error occurred in refundPayment", e)
}
return paymentSessionData
}
async retrievePayment(
paymentSessionData: Record<string, unknown>
): Promise<PaymentProviderError | PaymentProviderSessionResponse["data"]> {
try {
const id = paymentSessionData.id as string
const intent = await this.stripe_.paymentIntents.retrieve(id)
return intent as unknown as PaymentProviderSessionResponse["data"]
} catch (e) {
return this.buildError("An error occurred in retrievePayment", e)
}
}
async updatePayment(
input: UpdatePaymentProviderSession
): Promise<PaymentProviderError | PaymentProviderSessionResponse> {
const { context, data, amount } = input
const amountNumeric = Math.round(new BigNumber(amount).numeric)
const stripeId = context.customer?.metadata?.stripe_id
if (stripeId !== data.customer) {
const result = await this.initiatePayment(input)
if (isPaymentProviderError(result)) {
return this.buildError(
"An error occurred in updatePayment during the initiate of the new payment for the new customer",
result
)
}
return result
} else {
if (amount && data.amount === amountNumeric) {
return { data }
}
try {
const id = data.id as string
const sessionData = (await this.stripe_.paymentIntents.update(id, {
amount: amountNumeric,
})) as unknown as PaymentProviderSessionResponse["data"]
return { data: sessionData }
} catch (e) {
return this.buildError("An error occurred in updatePayment", e)
}
}
}
async updatePaymentData(sessionId: string, data: Record<string, unknown>) {
try {
// Prevent from updating the amount from here as it should go through
// the updatePayment method to perform the correct logic
if (data.amount) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot update amount, use updatePayment instead"
)
}
return (await this.stripe_.paymentIntents.update(sessionId, {
...data,
})) as unknown as PaymentProviderSessionResponse["data"]
} catch (e) {
return this.buildError("An error occurred in updatePaymentData", e)
}
}
async getWebhookActionAndData(
webhookData: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
const event = this.constructWebhookEvent(webhookData)
const intent = event.data.object as Stripe.PaymentIntent
switch (event.type) {
case "payment_intent.amount_capturable_updated":
return {
action: PaymentActions.AUTHORIZED,
data: {
resource_id: intent.metadata.resource_id,
amount: intent.amount_capturable, // NOTE: revisit when implementing multicapture
},
}
case "payment_intent.succeeded":
return {
action: PaymentActions.SUCCESSFUL,
data: {
resource_id: intent.metadata.resource_id,
amount: intent.amount_received,
},
}
case "payment_intent.payment_failed":
return {
action: PaymentActions.FAILED,
data: {
resource_id: intent.metadata.resource_id,
amount: intent.amount,
},
}
default:
return { action: PaymentActions.NOT_SUPPORTED }
}
}
/**
* Constructs Stripe Webhook event
* @param {object} data - the data of the webhook request: req.body
* ensures integrity of the webhook event
* @return {object} Stripe Webhook event
*/
constructWebhookEvent(data: ProviderWebhookPayload["payload"]): Stripe.Event {
const signature = data.headers["stripe-signature"] as string
return this.stripe_.webhooks.constructEvent(
data.rawData as string | Buffer,
signature,
this.config.webhookSecret
)
}
protected buildError(
message: string,
error: Stripe.StripeRawError | PaymentProviderError | Error
): PaymentProviderError {
return {
error: message,
code: "code" in error ? error.code : "unknown",
detail: isPaymentProviderError(error)
? `${error.error}${EOL}${error.detail ?? ""}`
: "detail" in error
? error.detail
: error.message ?? "",
}
}
}
export default StripeBase
@@ -0,0 +1,24 @@
import { ModuleProviderExports } from "@medusajs/types"
import {
StripeBancontactService,
StripeBlikService,
StripeGiropayService,
StripeIdealService,
StripeProviderService,
StripePrzelewy24Service,
} from "./services"
const services = [
StripeBancontactService,
StripeBlikService,
StripeGiropayService,
StripeIdealService,
StripeProviderService,
StripePrzelewy24Service,
]
const providerExport: ModuleProviderExports = {
services,
}
export default providerExport
@@ -0,0 +1,7 @@
export { default as StripeBancontactService } from "./stripe-bancontact"
export { default as StripeBlikService } from "./stripe-blik"
export { default as StripeGiropayService } from "./stripe-giropay"
export { default as StripeIdealService } from "./stripe-ideal"
export { default as StripeProviderService } from "./stripe-provider"
export { default as StripePrzelewy24Service } from "./stripe-przelewy24"
@@ -0,0 +1,19 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class BancontactProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.BAN_CONTACT
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {
payment_method_types: ["bancontact"],
capture_method: "automatic",
}
}
}
export default BancontactProviderService
@@ -0,0 +1,19 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class BlikProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.BLIK
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {
payment_method_types: ["blik"],
capture_method: "automatic",
}
}
}
export default BlikProviderService
@@ -0,0 +1,19 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class GiropayProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.GIROPAY
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {
payment_method_types: ["giropay"],
capture_method: "automatic",
}
}
}
export default GiropayProviderService
@@ -0,0 +1,19 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class IdealProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.IDEAL
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {
payment_method_types: ["ideal"],
capture_method: "automatic",
}
}
}
export default IdealProviderService
@@ -0,0 +1,16 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class StripeProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.STRIPE
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {}
}
}
export default StripeProviderService
@@ -0,0 +1,19 @@
import StripeBase from "../core/stripe-base"
import { PaymentIntentOptions, PaymentProviderKeys } from "../types"
class Przelewy24ProviderService extends StripeBase {
static PROVIDER = PaymentProviderKeys.PRZELEWY_24
constructor(_, options) {
super(_, options)
}
get paymentIntentOptions(): PaymentIntentOptions {
return {
payment_method_types: ["p24"],
capture_method: "automatic",
}
}
}
export default Przelewy24ProviderService
@@ -0,0 +1,44 @@
export interface StripeCredentials {
apiKey: string
webhookSecret: string
}
export interface StripeOptions {
credentials: Record<string, StripeCredentials>
/**
* Use this flag to capture payment immediately (default is false)
*/
capture?: boolean
/**
* set `automatic_payment_methods` to `{ enabled: true }`
*/
automatic_payment_methods?: boolean
/**
* Set a default description on the intent if the context does not provide one
*/
payment_description?: string
}
export interface PaymentIntentOptions {
capture_method?: "automatic" | "manual"
setup_future_usage?: "on_session" | "off_session"
payment_method_types?: string[]
}
export const ErrorCodes = {
PAYMENT_INTENT_UNEXPECTED_STATE: "payment_intent_unexpected_state",
}
export const ErrorIntentStatus = {
SUCCEEDED: "succeeded",
CANCELED: "canceled",
}
export const PaymentProviderKeys = {
STRIPE: "stripe",
BAN_CONTACT: "stripe-bancontact",
BLIK: "stripe-blik",
GIROPAY: "stripe-giropay",
IDEAL: "stripe-ideal",
PRZELEWY_24: "stripe-przelewy24",
}
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"es2019"
],
"target": "es5",
"jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"outDir": "./dist",
"esModuleInterop": true,
"declaration": true,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"downlevelIteration": true, // to use ES5 specific tooling
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
},
"include": ["src"],
"exclude": [
"dist",
"build",
"src/**/__tests__",
"src/**/__mocks__",
"src/**/__fixtures__",
"node_modules",
".eslintrc.js"
]
}
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules"]
}