Initial commit: backend, storefront, vendor-panel added

This commit is contained in:
2025-08-01 11:05:32 +08:00
commit 08174125d2
2958 changed files with 310810 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
{
"name": "@mercurjs/payout",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"!dist/**/__tests__",
"!dist/**/__mocks__",
"!dist/**/__fixtures__"
],
"engines": {
"node": ">=20"
},
"license": "MIT",
"scripts": {
"build": "rimraf dist && tsc --build",
"migration:initial": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts medusa-mikro-orm cache:clear"
},
"devDependencies": {
"@medusajs/framework": "2.8.6",
"@medusajs/test-utils": "2.8.6",
"@mercurjs/framework": "*",
"@mikro-orm/cli": "6.4.3",
"@mikro-orm/core": "6.4.3",
"@mikro-orm/migrations": "6.4.3",
"@mikro-orm/postgresql": "6.4.3",
"@swc/core": "^1.7.28",
"@swc/jest": "^0.2.36",
"jest": "^29.7.0",
"rimraf": "^3.0.2",
"tsc-alias": "^1.8.6",
"typescript": "^5.6.2"
},
"peerDependencies": {
"@medusajs/framework": "2.8.6",
"@mikro-orm/core": "6.4.3",
"@mikro-orm/migrations": "6.4.3",
"@mikro-orm/postgresql": "6.4.3",
"awilix": "^8.0.1"
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@medusajs/framework/utils";
import PayoutModuleService from "./service";
export const PAYOUT_MODULE = "payout";
export { PayoutModuleService };
export default Module(PAYOUT_MODULE, {
service: PayoutModuleService,
});

View File

@@ -0,0 +1,518 @@
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"status": {
"name": "status",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "'pending'",
"enumItems": [
"pending",
"active",
"disabled"
],
"mappedType": "enum"
},
"reference_id": {
"name": "reference_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"data": {
"name": "data",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"context": {
"name": "context",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "payout_account",
"schema": "public",
"indexes": [
{
"keyName": "IDX_payout_account_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_payout_account_deleted_at\" ON \"payout_account\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "payout_account_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"currency_code": {
"name": "currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"amount": {
"name": "amount",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "decimal"
},
"data": {
"name": "data",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"payout_account_id": {
"name": "payout_account_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"raw_amount": {
"name": "raw_amount",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "payout",
"schema": "public",
"indexes": [
{
"keyName": "IDX_payout_payout_account_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_payout_payout_account_id\" ON \"payout\" (payout_account_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_payout_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_payout_deleted_at\" ON \"payout\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "payout_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"payout_payout_account_id_foreign": {
"constraintName": "payout_payout_account_id_foreign",
"columnNames": [
"payout_account_id"
],
"localTableName": "public.payout",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.payout_account",
"updateRule": "cascade"
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"data": {
"name": "data",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"context": {
"name": "context",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"payout_account_id": {
"name": "payout_account_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "onboarding",
"schema": "public",
"indexes": [
{
"keyName": "IDX_onboarding_payout_account_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_onboarding_payout_account_id_unique\" ON \"onboarding\" (payout_account_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_onboarding_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_onboarding_deleted_at\" ON \"onboarding\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "onboarding_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"onboarding_payout_account_id_foreign": {
"constraintName": "onboarding_payout_account_id_foreign",
"columnNames": [
"payout_account_id"
],
"localTableName": "public.onboarding",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.payout_account",
"updateRule": "cascade"
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"currency_code": {
"name": "currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"amount": {
"name": "amount",
"type": "numeric",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "decimal"
},
"data": {
"name": "data",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "json"
},
"payout_id": {
"name": "payout_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"raw_amount": {
"name": "raw_amount",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "payout_reversal",
"schema": "public",
"indexes": [
{
"keyName": "IDX_payout_reversal_payout_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_payout_reversal_payout_id\" ON \"payout_reversal\" (payout_id) WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_payout_reversal_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_payout_reversal_deleted_at\" ON \"payout_reversal\" (deleted_at) WHERE deleted_at IS NULL"
},
{
"keyName": "payout_reversal_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"payout_reversal_payout_id_foreign": {
"constraintName": "payout_reversal_payout_id_foreign",
"columnNames": [
"payout_id"
],
"localTableName": "public.payout_reversal",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.payout",
"updateRule": "cascade"
}
},
"nativeEnums": {}
}
],
"nativeEnums": {}
}

View File

@@ -0,0 +1,35 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20250317090626 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "onboarding" drop constraint if exists "onboarding_payout_account_id_unique";`);
this.addSql(`create table if not exists "payout_account" ("id" text not null, "status" text check ("status" in ('pending', 'active', 'disabled')) not null default 'pending', "reference_id" text not null, "data" jsonb not null, "context" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "payout_account_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_payout_account_deleted_at" ON "payout_account" (deleted_at) WHERE deleted_at IS NULL;`);
this.addSql(`create table if not exists "payout" ("id" text not null, "currency_code" text not null, "amount" numeric not null, "data" jsonb null, "payout_account_id" text not null, "raw_amount" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "payout_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_payout_payout_account_id" ON "payout" (payout_account_id) WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_payout_deleted_at" ON "payout" (deleted_at) WHERE deleted_at IS NULL;`);
this.addSql(`create table if not exists "onboarding" ("id" text not null, "data" jsonb null, "context" jsonb null, "payout_account_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "onboarding_pkey" primary key ("id"));`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_onboarding_payout_account_id_unique" ON "onboarding" (payout_account_id) WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_onboarding_deleted_at" ON "onboarding" (deleted_at) WHERE deleted_at IS NULL;`);
this.addSql(`alter table if exists "payout" add constraint "payout_payout_account_id_foreign" foreign key ("payout_account_id") references "payout_account" ("id") on update cascade;`);
this.addSql(`alter table if exists "onboarding" add constraint "onboarding_payout_account_id_foreign" foreign key ("payout_account_id") references "payout_account" ("id") on update cascade;`);
}
override async down(): Promise<void> {
this.addSql(`alter table if exists "payout" drop constraint if exists "payout_payout_account_id_foreign";`);
this.addSql(`alter table if exists "onboarding" drop constraint if exists "onboarding_payout_account_id_foreign";`);
this.addSql(`drop table if exists "payout_account" cascade;`);
this.addSql(`drop table if exists "payout" cascade;`);
this.addSql(`drop table if exists "onboarding" cascade;`);
}
}

View File

@@ -0,0 +1,27 @@
import { Migration } from '@mikro-orm/migrations'
export class Migration20250612144913 extends Migration {
override async up(): Promise<void> {
this.addSql(
`create table if not exists "payout_reversal" ("id" text not null, "currency_code" text not null, "amount" numeric not null, "data" jsonb null, "payout_id" text not null, "raw_amount" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "payout_reversal_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_payout_reversal_payout_id" ON "payout_reversal" (payout_id) WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_payout_reversal_deleted_at" ON "payout_reversal" (deleted_at) WHERE deleted_at IS NULL;`
)
this.addSql(
`alter table if exists "payout_reversal" add constraint "payout_reversal_payout_id_foreign" foreign key ("payout_id") references "payout" ("id") on update cascade;`
)
}
override async down(): Promise<void> {
this.addSql(
`alter table if exists "payout_reversal" drop constraint if exists "payout_reversal_payout_id_foreign";`
)
this.addSql(`drop table if exists "payout_reversal" cascade;`)
}
}

View File

@@ -0,0 +1,4 @@
export * from './payout-account'
export * from './payout'
export * from './onboarding'
export * from './payout-reversal'

View File

@@ -0,0 +1,12 @@
import { model } from '@medusajs/framework/utils'
import { PayoutAccount } from './payout-account'
export const Onboarding = model.define('onboarding', {
id: model.id({ prefix: 'onb' }).primaryKey(),
data: model.json().nullable(),
context: model.json().nullable(),
payout_account: model.belongsTo(() => PayoutAccount, {
mappedBy: 'onboarding'
})
})

View File

@@ -0,0 +1,15 @@
import { model } from "@medusajs/framework/utils";
import { PayoutAccountStatus } from "@mercurjs/framework";
import { Onboarding } from "./onboarding";
import { Payout } from "./payout";
export const PayoutAccount = model.define("payout_account", {
id: model.id({ prefix: "pacc" }).primaryKey(),
status: model.enum(PayoutAccountStatus).default(PayoutAccountStatus.PENDING),
reference_id: model.text(),
data: model.json(),
context: model.json().nullable(),
onboarding: model.hasOne(() => Onboarding).nullable(),
payouts: model.hasMany(() => Payout),
});

View File

@@ -0,0 +1,13 @@
import { model } from '@medusajs/framework/utils'
import { Payout } from './payout'
export const PayoutReversal = model.define('payout_reversal', {
id: model.id({ prefix: 'prev' }).primaryKey(),
currency_code: model.text(),
amount: model.bigNumber(),
data: model.json().nullable(),
payout: model.belongsTo(() => Payout, {
mappedBy: 'reversals'
})
})

View File

@@ -0,0 +1,15 @@
import { model } from '@medusajs/framework/utils'
import { PayoutAccount } from './payout-account'
import { PayoutReversal } from './payout-reversal'
export const Payout = model.define('payout', {
id: model.id({ prefix: 'pout' }).primaryKey(),
currency_code: model.text(),
amount: model.bigNumber(),
data: model.json().nullable(),
payout_account: model.belongsTo(() => PayoutAccount, {
mappedBy: 'payouts'
}),
reversals: model.hasMany(() => PayoutReversal)
})

View File

@@ -0,0 +1,229 @@
import { EntityManager } from "@mikro-orm/knex";
import { Context } from "@medusajs/framework/types";
import {
InjectTransactionManager,
MedusaContext,
MedusaError,
MedusaService,
} from "@medusajs/framework/utils";
import { Onboarding, Payout, PayoutAccount, PayoutReversal } from "./models";
import {
CreateOnboardingDTO,
CreatePayoutAccountDTO,
CreatePayoutDTO,
CreatePayoutReversalDTO,
IPayoutProvider,
PayoutAccountStatus,
PayoutWebhookActionPayload,
} from "@mercurjs/framework";
type InjectedDependencies = {
payoutProvider: IPayoutProvider;
};
class PayoutModuleService extends MedusaService({
Payout,
PayoutReversal,
PayoutAccount,
Onboarding,
}) {
protected provider_: IPayoutProvider;
constructor({ payoutProvider }: InjectedDependencies) {
super(...arguments);
this.provider_ = payoutProvider;
}
@InjectTransactionManager()
async createPayoutAccount(
{ context }: CreatePayoutAccountDTO,
@MedusaContext() sharedContext?: Context<EntityManager>
) {
const result = await this.createPayoutAccounts(
{ context, reference_id: "placeholder", data: {} },
sharedContext
);
try {
const { data, id: referenceId } =
await this.provider_.createPayoutAccount({
context,
account_id: result.id,
});
await this.updatePayoutAccounts(
{
id: result.id,
data,
reference_id: referenceId,
},
sharedContext
);
const updated = await this.retrievePayoutAccount(
result.id,
undefined,
sharedContext
);
return updated;
} catch (error) {
await this.deletePayoutAccounts(result.id, sharedContext);
throw error;
}
}
@InjectTransactionManager()
async syncStripeAccount(
account_id: string,
@MedusaContext() sharedContext?: Context<EntityManager>
) {
const payout_account = await this.retrievePayoutAccount(account_id);
const stripe_account = await this.provider_.getAccount(
payout_account.reference_id
);
const status =
stripe_account.details_submitted &&
stripe_account.payouts_enabled &&
stripe_account.charges_enabled &&
stripe_account.tos_acceptance &&
stripe_account.tos_acceptance?.date !== null;
await this.updatePayoutAccounts(
{
id: account_id,
data: stripe_account as unknown as Record<string, unknown>,
status: status
? PayoutAccountStatus.ACTIVE
: PayoutAccountStatus.PENDING,
},
sharedContext
);
const updated = await this.retrievePayoutAccount(
account_id,
undefined,
sharedContext
);
return updated;
}
@InjectTransactionManager()
async initializeOnboarding(
{ context, payout_account_id }: CreateOnboardingDTO,
@MedusaContext() sharedContext?: Context<EntityManager>
) {
const [existingOnboarding] = await this.listOnboardings({
payout_account_id,
});
const account = await this.retrievePayoutAccount(payout_account_id);
const { data: providerData } = await this.provider_.initializeOnboarding(
account.reference_id!,
context
);
let onboarding = existingOnboarding;
if (!existingOnboarding) {
onboarding = await super.createOnboardings(
{
payout_account_id,
},
sharedContext
);
}
await this.updateOnboardings(
{
id: onboarding.id,
data: providerData,
context,
},
sharedContext
);
return await this.retrieveOnboarding(
onboarding.id,
undefined,
sharedContext
);
}
@InjectTransactionManager()
async createPayout(
input: CreatePayoutDTO,
@MedusaContext() sharedContext?: Context<EntityManager>
) {
const {
amount,
currency_code,
account_id,
transaction_id,
source_transaction,
} = input;
const payoutAccount = await this.retrievePayoutAccount(account_id);
const { data } = await this.provider_.createPayout({
account_reference_id: payoutAccount.reference_id,
amount,
currency: currency_code,
transaction_id,
source_transaction,
});
// @ts-expect-error BigNumber incompatible interface
const payout = await this.createPayouts(
{
data,
amount,
currency_code,
payout_account: payoutAccount.id,
},
sharedContext
);
return payout;
}
@InjectTransactionManager()
async createPayoutReversal(
input: CreatePayoutReversalDTO,
@MedusaContext() sharedContext?: Context<EntityManager>
) {
const payout = await this.retrievePayout(input.payout_id);
if (!payout || !payout.data || !payout.data.id) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Payout not found");
}
const transfer_id = payout.data.id as string;
const transferReversal = await this.provider_.reversePayout({
transfer_id,
amount: input.amount,
currency: input.currency_code,
});
// @ts-expect-error BigNumber incompatible interface
const payoutReversal = await this.createPayoutReversals(
{
data: transferReversal as unknown as Record<string, unknown>,
amount: input.amount,
currency_code: input.currency_code,
payout: payout.id,
},
sharedContext
);
return payoutReversal;
}
async getWebhookActionAndData(input: PayoutWebhookActionPayload) {
return await this.provider_.getWebhookActionAndData(input);
}
}
export default PayoutModuleService;

View File

@@ -0,0 +1 @@
export * from './provider'

View File

@@ -0,0 +1,214 @@
import Stripe from "stripe";
import { ConfigModule, Logger } from "@medusajs/framework/types";
import { MedusaError, isPresent } from "@medusajs/framework/utils";
import { PAYOUT_MODULE } from "..";
import {
CreatePayoutAccountInput,
CreatePayoutAccountResponse,
IPayoutProvider,
InitializeOnboardingResponse,
PayoutWebhookAction,
PayoutWebhookActionPayload,
ProcessPayoutInput,
ProcessPayoutResponse,
ReversePayoutInput,
getSmallestUnit,
} from "@mercurjs/framework";
type InjectedDependencies = {
logger: Logger;
configModule: ConfigModule;
};
type StripeConnectConfig = {
apiKey: string;
webhookSecret: string;
};
export class PayoutProvider implements IPayoutProvider {
protected readonly config_: StripeConnectConfig;
protected readonly logger_: Logger;
protected readonly client_: Stripe;
constructor({ logger, configModule }: InjectedDependencies) {
this.logger_ = logger;
const moduleDef = configModule.modules?.[PAYOUT_MODULE];
if (typeof moduleDef !== "boolean" && moduleDef?.options) {
this.config_ = {
apiKey: moduleDef.options.apiKey as string,
webhookSecret: moduleDef.options.webhookSecret as string,
};
}
this.client_ = new Stripe(this.config_.apiKey, {
apiVersion: "2025-02-24.acacia",
});
}
async createPayout({
amount,
currency,
account_reference_id,
transaction_id,
source_transaction,
}: ProcessPayoutInput): Promise<ProcessPayoutResponse> {
try {
this.logger_.info(
`Processing payout for transaction with ID ${transaction_id}`
);
const transfer = await this.client_.transfers.create(
{
currency,
destination: account_reference_id,
amount: getSmallestUnit(amount, currency),
source_transaction,
metadata: {
transaction_id,
},
},
{ idempotencyKey: transaction_id }
);
return {
data: transfer as unknown as Record<string, unknown>,
};
} catch (error) {
this.logger_.error("Error occured while creating payout", error);
const message = error?.message ?? "Error occured while creating payout";
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message);
}
}
async createPayoutAccount({
context,
account_id,
}: CreatePayoutAccountInput): Promise<CreatePayoutAccountResponse> {
try {
const { country } = context;
this.logger_.info("Creating payment profile");
if (!isPresent(country)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`"country" is required`
);
}
const account = await this.client_.accounts.create({
country: country as string,
type: "standard",
metadata: {
account_id,
},
});
return {
data: account as unknown as Record<string, unknown>,
id: account.id,
};
} catch (error) {
const message =
error?.message ?? "Error occured while creating payout account";
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message);
}
}
async initializeOnboarding(
accountId: string,
context: Record<string, unknown>
): Promise<InitializeOnboardingResponse> {
try {
this.logger_.info("Initializing onboarding");
if (!isPresent(context.refresh_url)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`'refresh_url' is required`
);
}
if (!isPresent(context.return_url)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`'return_url' is required`
);
}
const accountLink = await this.client_.accountLinks.create({
account: accountId,
refresh_url: context.refresh_url as string,
return_url: context.return_url as string,
type: "account_onboarding",
});
return {
data: accountLink as unknown as Record<string, unknown>,
};
} catch (error) {
const message =
error?.message ?? "Error occured while initializing onboarding";
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message);
}
}
async getAccount(accountId: string): Promise<Stripe.Account> {
try {
const account = await this.client_.accounts.retrieve(accountId);
return account;
} catch (error) {
const message = error?.message ?? "Error occured while getting account";
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message);
}
}
async reversePayout(input: ReversePayoutInput) {
try {
const reversal = await this.client_.transfers.createReversal(
input.transfer_id,
{
amount: getSmallestUnit(input.amount, input.currency),
}
);
return reversal;
} catch (error) {
const message = error?.message ?? "Error occured while reversing payout";
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, message);
}
}
async getWebhookActionAndData(payload: PayoutWebhookActionPayload) {
const signature = payload.headers["stripe-signature"] as string;
const event = this.client_.webhooks.constructEvent(
payload.rawData as string | Buffer,
signature,
this.config_.webhookSecret
);
const data = event.data.object as Stripe.Account;
switch (event.type) {
case "account.updated":
// here you can validate account data to make sure it's valid
return {
action: PayoutWebhookAction.ACCOUNT_AUTHORIZED,
data: {
account_id: data.metadata?.account_id as string,
},
};
}
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`Unsupported event type: ${event.type}`
);
}
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["ES2021"],
"target": "ES2021",
"outDir": "${configDir}/dist",
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"noUnusedLocals": true,
"module": "node16",
"moduleResolution": "node16",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"allowJs": true,
"skipLibCheck": true,
"incremental": false
},
"include": ["${configDir}/src"],
"exclude": ["${configDir}/dist", "${configDir}/node_modules"]
}