Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/billing/payment-methods.server.ts
T

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