feat(MED-99): use montonio api and webhook

This commit is contained in:
2025-07-17 10:08:52 +03:00
parent 00b079e170
commit 02bb9f7d34
19 changed files with 813 additions and 143 deletions

View File

@@ -19,3 +19,7 @@ EMAIL_PORT= # or 465 for SSL
EMAIL_TLS= # or false for SSL (see provider documentation)
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

View File

@@ -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",

View File

@@ -13,6 +13,7 @@ export const BillingProviderSchema = z.enum([
'stripe',
'paddle',
'lemon-squeezy',
'montonio',
]);
export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);

View File

@@ -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>;
}

View File

@@ -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:*",

View File

@@ -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(

View File

@@ -0,0 +1,4 @@
# Billing / Montonio - @kit/montonio
This package is responsible for handling all billing related operations using Montonio.

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View 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/*"
]
}
}
}

View File

@@ -0,0 +1,2 @@
export { MontonioWebhookHandlerService } from './services/montonio-webhook-handler.service';
export { MontonioOrderHandlerService } from './services/montonio-order-handler.service';

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const MontonioClientEnvSchema = z
.object({
accessKey: z.string().min(1),
});

View File

@@ -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),
});

View File

@@ -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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -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:*",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
alter type public.billing_provider add value 'montonio';