Files
calendar/packages/app-store/btcpayserver/lib/PaymentService.ts
T
Benny JooGitHubbenny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>benny@cal.com <sldisek783@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
96bec9f7b9 refactor: Move repositories from @calcom/lib to @calcom/features domain folders (#27570)
* refactor: move repositories from lib to features domain folders

- Move HolidayRepository to features/holidays/repositories
- Move PrismaTrackingRepository to features/bookings/repositories
- Move PrismaBookingPaymentRepository to features/bookings/repositories
- Move PrismaRoutingFormResponseRepository to features/routing-forms/repositories
- Move PrismaAssignmentReasonRepository to features/assignment-reason/repositories
- Move VerificationTokenRepository to features/auth/repositories
- Move WorkspacePlatformRepository to features/workspace-platform/repositories
- Move DTO files to their respective feature domains
- Merge lib DestinationCalendarRepository into features version
- Merge lib SelectedCalendarRepository into features version
- Update all import paths across the codebase

This follows the vertical slice architecture pattern by organizing
repositories by domain rather than by technical layer.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update VerificationTokenService import path to new location

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update test file imports to use new repository locations

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* mv

* fix structure

* fix

* refactor: merge unit tests for SelectedCalendarRepository into single file

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-09 08:41:04 -03:00

215 lines
7.1 KiB
TypeScript

import { v4 as uuidv4 } from "uuid";
import type z from "zod";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { IBookingPaymentRepository } from "@calcom/features/bookings/repositories/BookingPaymentRepository.interface";
import { PrismaBookingPaymentRepository } from "@calcom/features/bookings/repositories/PrismaBookingPaymentRepository";
import type { Booking, Payment, PaymentOption } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import appConfig from "../config.json";
import { btcpayCredentialKeysSchema } from "./btcpayCredentialKeysSchema";
import { convertFromSmallestToPresentableCurrencyUnit } from "./currencyOptions";
const log = logger.getSubLogger({ prefix: ["payment-service:btcpayserver"] });
interface BTCPayInvoice {
id: string;
checkoutLink: string;
status: string;
amount: string;
currency: string;
createdTime: number;
expirationTime: number;
metadata?: Record<string, any>;
checkout?: Record<string, any>;
receipt?: Record<string, any>;
payments?: Array<{
id: string;
amount: string;
paymentMethod: string;
}>;
[key: string]: any;
}
class BTCPayServerPaymentService implements IAbstractPaymentService {
private credentials: z.infer<typeof btcpayCredentialKeysSchema> | null;
private bookingPaymentRepository: IBookingPaymentRepository;
constructor(
credentials: { key: Prisma.JsonValue },
bookingPaymentRepository: IBookingPaymentRepository = new PrismaBookingPaymentRepository()
) {
const keyParsing = btcpayCredentialKeysSchema.safeParse(credentials.key);
if (keyParsing.success) {
this.credentials = keyParsing.data;
} else {
this.credentials = null;
}
this.bookingPaymentRepository = bookingPaymentRepository;
}
private async BTCPayApiCall(endpoint: string, options: RequestInit = {}) {
if (!this.credentials) throw new Error("BTCPay server credentials not found");
const serverUrl = this.credentials.serverUrl.endsWith("/")
? this.credentials.serverUrl.slice(0, -1)
: this.credentials.serverUrl;
const url = `${serverUrl}${endpoint}`;
const headers = {
Authorization: `token ${this.credentials.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
};
try {
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`BTCPay server API error (${response.status}): ${errorText}`);
}
return await response.json();
} catch (error) {
throw error;
}
}
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
userId: Booking["userId"],
username: string | null,
bookerName: string,
paymentOption: PaymentOption,
bookerEmail: string,
bookerPhoneNumber?: string | null,
eventTitle?: string,
bookingTitle?: string
) {
try {
if (!this.credentials?.storeId) {
throw new Error("BTCPay server: Store ID not found");
}
const uid = uuidv4();
const invoiceRequest = {
metadata: {
orderId: `cal-booking-${bookingId}`,
itemDesc: bookingTitle || `Booking #${bookingId}`,
appId: "cal.com",
referenceId: uid,
customerName: bookerName,
customerEmail: bookerEmail,
bookingDescription: bookingTitle || `Booking with ${bookerName}`,
},
checkout: {
buyerEmail: bookerEmail,
},
receipt: {
enabled: true,
},
amount: convertFromSmallestToPresentableCurrencyUnit(payment.amount, payment.currency),
currency: payment.currency === "BTC" ? "SATS" : payment.currency,
additionalSearchTerms: [`cal-booking-${bookingId}`, bookerName, bookerEmail],
};
const invoiceResponse = (await this.BTCPayApiCall(
`/api/v1/stores/${this.credentials.storeId}/invoices`,
{ method: "POST", body: JSON.stringify(invoiceRequest) }
)) as BTCPayInvoice;
const paymentData = await this.bookingPaymentRepository.createPaymentRecord({
uid,
app: { connect: { slug: appConfig.slug } },
booking: { connect: { id: bookingId } },
amount: payment.amount,
externalId: invoiceResponse.id,
currency: payment.currency,
fee: 0,
success: false,
refunded: false,
data: Object.assign(
{},
{
invoice: {
...invoiceResponse,
isPaid: false,
attendee: { name: bookerName, email: bookerEmail },
},
}
),
});
if (!paymentData) throw new Error("Failed to store Payment data");
return paymentData;
} catch (error) {
log.error("BTCPay server: Payment could not be created", bookingId, safeStringify(error));
throw new Error(ErrorCode.PaymentCreationFailure);
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("BTCPay Server does not support automatic refunds for Bitcoin payments");
}
async collectCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number,
_bookerEmail: string,
_paymentOption: PaymentOption
): Promise<Payment> {
throw new Error("Method not implemented");
}
chargeCard(
_payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
_bookingId: number
): Promise<Payment> {
throw new Error("Method not implemented.");
}
async getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
async getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async afterPayment(
_event: CalendarEvent,
_booking: {
user: { email: string | null; name: string | null; timeZone: string } | null;
id: number;
startTime: { toISOString: () => string };
uid: string;
},
_paymentData: Payment
): Promise<void> {
return Promise.resolve();
}
deletePayment(_paymentId: number): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return !!this.credentials;
}
}
/**
* Factory function that creates a BTCPay Server Payment service instance.
* This is exported instead of the class to prevent internal types
* from leaking into the emitted .d.ts file.
*/
export function BuildPaymentService(credentials: { key: Prisma.JsonValue }): IAbstractPaymentService {
return new BTCPayServerPaymentService(credentials);
}