feat(MED-99): use montonio api and webhook
This commit is contained in:
@@ -18,4 +18,8 @@ EMAIL_HOST= # refer to your email provider's documentation
|
||||
EMAIL_PORT= # or 465 for SSL
|
||||
EMAIL_TLS= # or false for SSL (see provider documentation)
|
||||
|
||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=
|
||||
|
||||
NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead
|
||||
MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo
|
||||
MONTONIO_API_URL=https://sandbox-stargate.montonio.com
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
@@ -92,6 +93,7 @@
|
||||
"@medusajs/ui-preset": "latest",
|
||||
"@next/bundle-analyzer": "15.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/node": "^22.15.32",
|
||||
"@types/react": "19.1.4",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const BillingProviderSchema = z.enum([
|
||||
'stripe',
|
||||
'paddle',
|
||||
'lemon-squeezy',
|
||||
'montonio',
|
||||
]);
|
||||
|
||||
export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import { UpsertOrderParams, UpsertSubscriptionParams } from '../types';
|
||||
|
||||
export interface IHandleWebhookEventParams {
|
||||
// this method is called when a checkout session is completed
|
||||
onCheckoutSessionCompleted: (
|
||||
subscription: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is updated
|
||||
onSubscriptionUpdated: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is deleted
|
||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is succeeded. This is used for
|
||||
// one-time payments
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is failed. This is used for
|
||||
// one-time payments
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when an invoice is paid. We don't have a specific use case for this
|
||||
// but it's extremely common for credit-based systems
|
||||
onInvoicePaid: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// generic handler for any event
|
||||
onEvent?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name BillingWebhookHandlerService
|
||||
* @description Represents an abstract class for handling billing webhook events.
|
||||
@@ -20,36 +52,6 @@ export abstract class BillingWebhookHandlerService {
|
||||
*/
|
||||
abstract handleWebhookEvent(
|
||||
event: unknown,
|
||||
params: {
|
||||
// this method is called when a checkout session is completed
|
||||
onCheckoutSessionCompleted: (
|
||||
subscription: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is updated
|
||||
onSubscriptionUpdated: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is deleted
|
||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is succeeded. This is used for
|
||||
// one-time payments
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is failed. This is used for
|
||||
// one-time payments
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when an invoice is paid. We don't have a specific use case for this
|
||||
// but it's extremely common for credit-based systems
|
||||
onInvoicePaid: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// generic handler for any event
|
||||
onEvent?: (data: unknown) => Promise<unknown>;
|
||||
},
|
||||
params: IHandleWebhookEventParams,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/stripe": "workspace:*",
|
||||
"@kit/montonio": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
|
||||
@@ -30,6 +30,13 @@ export function createBillingEventHandlerFactoryService(
|
||||
return new StripeWebhookHandlerService(planTypesMap);
|
||||
});
|
||||
|
||||
// Register the Montonio webhook handler
|
||||
billingWebhookHandlerRegistry.register('montonio', async () => {
|
||||
const { MontonioWebhookHandlerService } = await import('@kit/montonio');
|
||||
|
||||
return new MontonioWebhookHandlerService();
|
||||
});
|
||||
|
||||
// Register the Lemon Squeezy webhook handler
|
||||
billingWebhookHandlerRegistry.register('lemon-squeezy', async () => {
|
||||
const { LemonSqueezyWebhookHandlerService } = await import(
|
||||
|
||||
4
packages/billing/montonio/README.md
Normal file
4
packages/billing/montonio/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Billing / Montonio - @kit/montonio
|
||||
|
||||
This package is responsible for handling all billing related operations using Montonio.
|
||||
|
||||
3
packages/billing/montonio/eslint.config.mjs
Normal file
3
packages/billing/montonio/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
35
packages/billing/montonio/package.json
Normal file
35
packages/billing/montonio/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@kit/montonio",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/billing/montonio/src/index.ts
Normal file
2
packages/billing/montonio/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MontonioWebhookHandlerService } from './services/montonio-webhook-handler.service';
|
||||
export { MontonioOrderHandlerService } from './services/montonio-order-handler.service';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MontonioClientEnvSchema = z
|
||||
.object({
|
||||
accessKey: z.string().min(1),
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const MontonioServerEnvSchema = z
|
||||
.object({
|
||||
secretKey: z
|
||||
.string({
|
||||
required_error: `Please provide the variable MONTONIO_SECRET_KEY`,
|
||||
})
|
||||
.min(1),
|
||||
apiUrl: z
|
||||
.string({
|
||||
required_error: `Please provide the variable MONTONIO_API_URL`,
|
||||
})
|
||||
.min(1),
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { MontonioClientEnvSchema } from '../schema/montonio-client-env.schema';
|
||||
import { MontonioServerEnvSchema } from '../schema/montonio-server-env.schema';
|
||||
|
||||
const { accessKey } = MontonioClientEnvSchema.parse({
|
||||
accessKey: process.env.NEXT_PUBLIC_MONTONIO_ACCESS_KEY,
|
||||
});
|
||||
|
||||
const { apiUrl, secretKey } = MontonioServerEnvSchema.parse({
|
||||
apiUrl: process.env.MONTONIO_API_URL,
|
||||
secretKey: process.env.MONTONIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
export class MontonioOrderHandlerService {
|
||||
public async getMontonioPaymentLink({
|
||||
notificationUrl,
|
||||
returnUrl,
|
||||
amount,
|
||||
currency,
|
||||
description,
|
||||
locale,
|
||||
merchantReference,
|
||||
}: {
|
||||
notificationUrl: string;
|
||||
returnUrl: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
locale: string;
|
||||
merchantReference: string;
|
||||
}) {
|
||||
const token = jwt.sign({
|
||||
accessKey,
|
||||
description,
|
||||
currency,
|
||||
amount,
|
||||
locale,
|
||||
// 15 minutes
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
notificationUrl,
|
||||
returnUrl,
|
||||
askAdditionalInfo: false,
|
||||
merchantReference,
|
||||
type: "one_time",
|
||||
}, secretKey, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: "10m",
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${apiUrl}/api/payment-links`, { data: token });
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
console.error(error.response?.data);
|
||||
}
|
||||
console.error(error);
|
||||
throw new Error("Failed to create payment link");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { BillingWebhookHandlerService, IHandleWebhookEventParams } from '@kit/billing';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database, Enums } from '@kit/supabase/database';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { MontonioServerEnvSchema } from '../schema/montonio-server-env.schema';
|
||||
|
||||
type UpsertOrderParams =
|
||||
Database['medreport']['Functions']['upsert_order']['Args'];
|
||||
|
||||
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
|
||||
|
||||
interface MontonioOrderToken {
|
||||
uuid: string;
|
||||
accessKey: string;
|
||||
merchantReference: string;
|
||||
merchantReferenceDisplay: string;
|
||||
paymentStatus: 'PAID' | 'FAILED' | 'CANCELLED' | 'PENDING' | 'EXPIRED' | 'REFUNDED';
|
||||
paymentMethod: string;
|
||||
grandTotal: number;
|
||||
currency: string;
|
||||
senderIban?: string;
|
||||
senderName?: string;
|
||||
paymentProviderName?: string;
|
||||
paymentLinkUuid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const { secretKey } = MontonioServerEnvSchema.parse({
|
||||
apiUrl: process.env.MONTONIO_API_URL,
|
||||
secretKey: process.env.MONTONIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
export class MontonioWebhookHandlerService
|
||||
implements BillingWebhookHandlerService
|
||||
{
|
||||
private readonly provider: BillingProvider = 'montonio';
|
||||
private readonly namespace = 'billing.montonio';
|
||||
|
||||
async verifyWebhookSignature(request: Request) {
|
||||
const logger = await getLogger();
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const searchParams = url.searchParams;
|
||||
console.info("searchParams", searchParams, url);
|
||||
const tokenParam = searchParams.get('order-token') as string | null;
|
||||
if (!tokenParam) {
|
||||
throw new Error('Missing order-token');
|
||||
}
|
||||
token = tokenParam;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error,
|
||||
name: this.namespace,
|
||||
}, `Failed to parse Montonio webhook request`);
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, secretKey, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
return decoded as MontonioOrderToken;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error,
|
||||
name: this.namespace,
|
||||
}, `Failed to verify Montonio webhook signature`);
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebhookEvent(
|
||||
event: MontonioOrderToken,
|
||||
params: IHandleWebhookEventParams
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({
|
||||
name: this.namespace,
|
||||
event,
|
||||
}, `Received Montonio webhook event`);
|
||||
|
||||
if (event.paymentStatus === 'PAID') {
|
||||
const accountId = event.merchantReferenceDisplay.split(':')[0];
|
||||
if (!accountId) {
|
||||
throw new Error('Invalid merchant reference');
|
||||
}
|
||||
const order: UpsertOrderParams = {
|
||||
target_account_id: accountId,
|
||||
target_customer_id: '',
|
||||
target_order_id: event.uuid,
|
||||
status: 'succeeded',
|
||||
billing_provider: this.provider,
|
||||
total_amount: event.grandTotal,
|
||||
currency: event.currency,
|
||||
line_items: [],
|
||||
};
|
||||
return params.onCheckoutSessionCompleted(order);
|
||||
}
|
||||
|
||||
if (event.paymentStatus === 'FAILED' || event.paymentStatus === 'CANCELLED') {
|
||||
return params.onPaymentFailed(event.uuid);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
8
packages/billing/montonio/tsconfig.json
Normal file
8
packages/billing/montonio/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/stripe": "workspace:*",
|
||||
"@kit/montonio": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/team-accounts": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
|
||||
@@ -1736,7 +1736,7 @@ export type Database = {
|
||||
| "settings.manage"
|
||||
| "members.manage"
|
||||
| "invites.manage"
|
||||
billing_provider: "stripe" | "lemon-squeezy" | "paddle"
|
||||
billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio"
|
||||
notification_channel: "in_app" | "email"
|
||||
notification_type: "info" | "warning" | "error"
|
||||
payment_status: "pending" | "succeeded" | "failed"
|
||||
@@ -1938,7 +1938,7 @@ export const Constants = {
|
||||
"members.manage",
|
||||
"invites.manage",
|
||||
],
|
||||
billing_provider: ["stripe", "lemon-squeezy", "paddle"],
|
||||
billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"],
|
||||
notification_channel: ["in_app", "email"],
|
||||
notification_type: ["info", "warning", "error"],
|
||||
payment_status: ["pending", "succeeded", "failed"],
|
||||
|
||||
622
pnpm-lock.yaml
generated
622
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1
supabase/migrations/20250717075135_montonio_type.sql
Normal file
1
supabase/migrations/20250717075135_montonio_type.sql
Normal file
@@ -0,0 +1 @@
|
||||
alter type public.billing_provider add value 'montonio';
|
||||
Reference in New Issue
Block a user