129 lines
5.3 KiB
TypeScript
129 lines
5.3 KiB
TypeScript
import {
|
|
createMolliePaymentGateway,
|
|
mollieSepaFirstPaymentMethods,
|
|
resolveMollieCheckoutPaymentMethodRequirement,
|
|
type CustomerPaymentGateway,
|
|
type MollieCheckoutPaymentMethodRequirement,
|
|
type MollieLocale,
|
|
type MolliePaymentMethodId,
|
|
type PaymentGatewayListMethodsInput,
|
|
} from "@hivegrid/billing";
|
|
import { loadWebConfig } from "@hivegrid/config";
|
|
import { buildSelfServiceCheckoutPaymentMethodsVM, type SelfServiceCheckoutPaymentMethodsVM } from "@hivegrid/view-models";
|
|
|
|
export type CheckoutPaymentMethodRequirement = MollieCheckoutPaymentMethodRequirement;
|
|
|
|
export type CheckoutPaymentMethodsInput = {
|
|
readonly amountCents: number;
|
|
/** Future recurring charge used for mandate policy when the first payment is zero. */
|
|
readonly policyAmountCents?: number;
|
|
readonly currency: string;
|
|
readonly sequenceType: PaymentGatewayListMethodsInput["sequenceType"];
|
|
readonly locale: string;
|
|
readonly requirement?: CheckoutPaymentMethodRequirement;
|
|
};
|
|
|
|
export class CheckoutPaymentMethodError extends Error {
|
|
override readonly name = "CheckoutPaymentMethodError";
|
|
|
|
constructor(readonly code: "PAYMENT_METHOD_INVALID" | "PAYMENT_METHOD_UNAVAILABLE") {
|
|
super(code);
|
|
}
|
|
}
|
|
|
|
function configuredGateway(): CustomerPaymentGateway | null {
|
|
const config = loadWebConfig().config.mollie;
|
|
if (!config.apiKey) return null;
|
|
return createMolliePaymentGateway({
|
|
apiKey: config.apiKey,
|
|
profileId: config.profileId,
|
|
timeoutMs: config.timeoutMs,
|
|
});
|
|
}
|
|
|
|
export function toMollieLocale(locale: string): MollieLocale | null {
|
|
const language = locale.trim().toLowerCase().split(/[-_]/u)[0];
|
|
if (language === "de") return "de_DE";
|
|
if (language === "en") return "en_GB";
|
|
if (language === "hu") return "hu_HU";
|
|
if (language === "pl") return "pl_PL";
|
|
return null;
|
|
}
|
|
|
|
function filterMethods(methods: Awaited<ReturnType<CustomerPaymentGateway["listPaymentMethods"]>>, requirement: CheckoutPaymentMethodRequirement) {
|
|
if (requirement === "any") return methods;
|
|
const allowed = new Set<string>(requirement === "card" ? ["creditcard"] : mollieSepaFirstPaymentMethods);
|
|
return methods.filter((method) => allowed.has(method.id));
|
|
}
|
|
|
|
function effectiveRequirement(input: CheckoutPaymentMethodsInput): CheckoutPaymentMethodRequirement {
|
|
// Mollie only supports a zero-value first payment for mandate-capable card
|
|
// checkouts in our enabled method set. iDEAL and the other SEPA setup methods
|
|
// require a positive amount, so never let the hosted fallback accept them for
|
|
// a trial or deferred change.
|
|
if (input.sequenceType === "first" && input.amountCents === 0) return "card";
|
|
try {
|
|
return resolveMollieCheckoutPaymentMethodRequirement({
|
|
amountCents: input.policyAmountCents ?? input.amountCents,
|
|
currency: input.currency,
|
|
configuredRequirement: input.requirement ?? "any",
|
|
});
|
|
} catch {
|
|
throw new CheckoutPaymentMethodError("PAYMENT_METHOD_INVALID");
|
|
}
|
|
}
|
|
|
|
function methodQuery(input: CheckoutPaymentMethodsInput): PaymentGatewayListMethodsInput {
|
|
if (!Number.isSafeInteger(input.amountCents) || input.amountCents < 0) {
|
|
throw new CheckoutPaymentMethodError("PAYMENT_METHOD_INVALID");
|
|
}
|
|
return {
|
|
amountCents: input.amountCents,
|
|
currency: input.currency,
|
|
sequenceType: input.sequenceType,
|
|
locale: toMollieLocale(input.locale),
|
|
};
|
|
}
|
|
|
|
export async function loadCheckoutPaymentMethods(
|
|
input: CheckoutPaymentMethodsInput,
|
|
deps: { readonly gateway?: CustomerPaymentGateway | null } = {}
|
|
): Promise<SelfServiceCheckoutPaymentMethodsVM> {
|
|
const gateway = "gateway" in deps ? deps.gateway : configuredGateway();
|
|
const requirement = effectiveRequirement(input);
|
|
if (!gateway) return buildSelfServiceCheckoutPaymentMethodsVM(null, requirement === "card" ? "card" : undefined);
|
|
try {
|
|
const methods = filterMethods(await gateway.listPaymentMethods(methodQuery(input)), requirement);
|
|
return buildSelfServiceCheckoutPaymentMethodsVM(
|
|
methods.map((method) => ({ id: method.id, label: method.description })),
|
|
requirement === "card" ? "card" : undefined
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof CheckoutPaymentMethodError) throw error;
|
|
return buildSelfServiceCheckoutPaymentMethodsVM(null, requirement === "card" ? "card" : undefined);
|
|
}
|
|
}
|
|
|
|
export async function assertCheckoutPaymentMethodAvailable(
|
|
method: string | null | undefined,
|
|
input: CheckoutPaymentMethodsInput,
|
|
deps: { readonly gateway?: CustomerPaymentGateway | null } = {}
|
|
): Promise<MolliePaymentMethodId | null> {
|
|
const normalized = method?.trim().toLowerCase() ?? "";
|
|
const requirement = effectiveRequirement(input);
|
|
if (!normalized && requirement !== "card") return null;
|
|
const gateway = "gateway" in deps ? deps.gateway : configuredGateway();
|
|
if (!gateway) throw new CheckoutPaymentMethodError("PAYMENT_METHOD_UNAVAILABLE");
|
|
|
|
let methods: Awaited<ReturnType<CustomerPaymentGateway["listPaymentMethods"]>>;
|
|
try {
|
|
methods = filterMethods(await gateway.listPaymentMethods(methodQuery(input)), requirement);
|
|
} catch (error) {
|
|
if (error instanceof CheckoutPaymentMethodError) throw error;
|
|
throw new CheckoutPaymentMethodError("PAYMENT_METHOD_UNAVAILABLE");
|
|
}
|
|
const available = methods.find((candidate) => candidate.id === (normalized || "creditcard"));
|
|
if (!available) throw new CheckoutPaymentMethodError("PAYMENT_METHOD_UNAVAILABLE");
|
|
return available.id;
|
|
}
|