← Files WixARCHIVED FILE
skills/wix-headless-templates/donations/app/wix/donations/donations-core.ts
20.6 KB · Oct 8, 2026 · 12:02 UTC
// Donation rules and DTO mapping — transport-agnostic, imported by BOTH transports: ./campaigns.ts
// + ./donate.ts (the SDK, managed Astro and React) and the REST twins in templates/donations/rest/
// (fetch, a static site or a port to another language). Every rule about amounts, fees, frequency,
// goal math, validation, the cart line and the redirect body lives HERE, once. A raw campaign
// may come from the SDK (`_id`, coverImage as a `wix:image://` string) or from REST (`id`,
// coverImage as { id, url }); the mappers accept both. Has its own formatMoney so it stands alone
// when stripped. Imports are type-only so a strip to JS emits no imports.
import type {
AmountPreset,
CampaignDetail,
CampaignStatus,
CampaignSummary,
DonationErrorCode,
DonationFrequency,
DonationInput,
DonationOptions,
DonationReceipt,
FrequencyOption,
GoalProgress,
} from "./types";
/** A raw entity as either transport returns it. */
export type Raw = Record<string, any>;
/** Media value + size → https URL. Injected: the SDK transport scales through @wix/sdk, REST through the URL form. */
export type ImgSrc = (value: any, width: number, height: number) => string;
/** App id of Wix Donations — the `appId` of every donation line item's catalogReference. */
export const DONATIONS_APP_ID = "333b456e-dd48-4d6b-b32b-9fd48d74e163";
/** App id of the eCom platform (Checkout & Orders) — a donation is an eCom checkout. */
export const ECOM_PLATFORM_APP_ID = "1380b703-ce81-ff05-f115-39571d94dfcd";
/** Wix's fixed processing-fee rate a donor may add (askDonorCoverFee); applied to every recurring charge. */
export const FEE_RATE = 0.029;
/** The donor note's maximum length (the Wix widget's default). */
export const NOTE_MAX = 100;
export const FREQUENCIES: DonationFrequency[] = ["ONE_TIME", "WEEK", "MONTH", "YEAR"];
export const FREQUENCY_LABELS: Record<DonationFrequency, string> = {
ONE_TIME: "One-time",
WEEK: "Weekly",
MONTH: "Monthly",
YEAR: "Yearly",
};
export const rawId = (raw: Raw | undefined | null): string => raw?._id ?? raw?.id ?? "";
// ---- query ----------------------------------------------------------------------------------------
export interface CampaignsQueryOptions {
/** 1–100, default 100. */
limit?: number;
}
/**
* The Query Donation Campaigns body both transports send: only non-archived campaigns (archived =
* hidden and cannot accept donations), oldest first (the Wix widget's default campaign is the first
* by creation date), cursor paging. `status` is NOT filterable — closed campaigns are filtered by the
* caller from the DTO. The SDK builder spells the same as `.eq("archived", false).ascending("_createdDate")`.
*/
export function campaignsQuery({ limit = 100 }: CampaignsQueryOptions = {}): Raw {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100.");
return { filter: { archived: false }, sort: [{ fieldName: "createdDate", order: "ASC" }], cursorPaging: { limit } };
}
// ---- money ----------------------------------------------------------------------------------------
/**
* A number in a currency, in the visitor's locale; whole amounts without decimals ("$25", "$12.50").
* "" when the currency is unknown — never a bare number posing as money, never an assumed USD.
*/
export function formatAmount(amount: number | null | undefined, currency: string | null | undefined): string {
if (amount == null || !Number.isFinite(amount) || !currency) return "";
const whole = Number.isInteger(amount);
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
minimumFractionDigits: whole ? 0 : 2,
maximumFractionDigits: 2,
}).format(amount);
} catch {
return `${amount} ${currency}`;
}
}
/**
* MultiCurrencyPrice { amount, convertedAmount, formattedAmount } → a display string. The API's
* own `formattedAmount` wins (the site's currency symbol and locale); otherwise the amount is
* formatted in `currency`; "" when neither is available.
*/
export function formatMoney(money: Raw | null | undefined, currency: string | null | undefined): string {
if (money?.formattedConvertedAmount) return String(money.formattedConvertedAmount);
if (money?.formattedAmount) return String(money.formattedAmount);
const value = money?.convertedAmount ?? money?.amount;
if (value == null || value === "") return "";
return formatAmount(Number(value), currency);
}
const amountOf = (money: Raw | null | undefined): number => {
const n = Number(money?.amount ?? 0);
return Number.isFinite(n) ? n : 0;
};
// ---- campaign rules -------------------------------------------------------------------------------
/** `status` is read-only and computed by Wix; anything unknown reads as collecting. */
export function statusOf(raw: Raw): CampaignStatus {
if (raw.status === "GOAL_REACHED") return "goalReached";
if (raw.status === "EXPIRED") return "expired";
return "collecting";
}
export const isArchived = (raw: Raw | null | undefined): boolean => raw?.archived === true;
/**
* The cover image as a value imgSrc resolves: the SDK hands a `wix:image://…` string; REST hands
* an Image object { id, url } — its url when present, else the media id in the wix:image form the
* URL builder scales. "" when the campaign has no image.
*/
export function campaignImage(raw: Raw): string {
const img = raw.coverImage;
if (!img) return "";
if (typeof img === "string") return img;
if (img.url) return String(img.url);
return img.id ? `wix:image://v1/${img.id}/${img.id}` : "";
}
/**
* The currency the campaign's amounts are in. The metrics endpoint "currently returns only the
* site's default currency", so its first entry's currencyCode is the site currency. "" when the
* metrics could not be read (the API's formattedAmount strings still display; only the live
* custom-amount total loses its label).
*/
export function currencyOf(metrics: Raw[] | null | undefined): string {
return metrics?.find((m) => m?.currencyCode)?.currencyCode ?? "";
}
/** The metrics entry for `currency` (the first entry when no currency is known); zero when absent. */
export function metricsFor(metrics: Raw[] | null | undefined, currency: string): { donationCount: number; totalAmount: Raw | undefined } {
const list = metrics ?? [];
const entry = currency ? list.find((m) => m?.currencyCode === currency) : list[0];
return { donationCount: Number(entry?.donationCount ?? 0) || 0, totalAmount: entry?.totalAmount };
}
/** Wix's progress rounding: 0 target → 0; under 1% rounds up, over 99% rounds down, else nearest. */
export function goalPercent(raised: number, target: number): number {
if (!target) return 0;
const v = (raised / target) * 100;
if (v < 1) return Math.ceil(v);
if (v > 99) return Math.floor(v);
return Math.round(v);
}
const sameDay = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
/** campaignGoal + metrics → GoalProgress; null when the campaign has no goal. `now` is injectable for tests. */
export function toGoal(campaignGoal: Raw | null | undefined, metrics: Raw[] | null | undefined, currency: string, now: Date = new Date()): GoalProgress | null {
if (!campaignGoal?.targetAmount) return null;
const m = metricsFor(metrics, currency);
const raisedAmount = amountOf(m.totalAmount);
const targetAmount = amountOf(campaignGoal.targetAmount);
const end = campaignGoal.endDate ? new Date(campaignGoal.endDate) : null;
const hasEnd = !!end && !Number.isNaN(end.getTime());
const ended = hasEnd && end!.getTime() < now.getTime();
const left = hasEnd && !ended ? end!.getTime() - now.getTime() : 0;
return {
raised: m.totalAmount ? formatMoney(m.totalAmount, currency) : formatAmount(0, currency),
target: formatMoney(campaignGoal.targetAmount, currency),
raisedAmount,
targetAmount,
percent: goalPercent(raisedAmount, targetAmount),
donationCount: m.donationCount,
reached: raisedAmount >= targetAmount,
endDate: hasEnd ? end!.toISOString() : null,
ended,
lastDay: hasEnd && sameDay(end!, now),
daysLeft: left ? Math.floor(left / 86_400_000) + 1 : 0,
hoursLeft: left ? Math.floor(left / 3_600_000) + 1 : 0,
};
}
export function presetsOf(raw: Raw, currency: string): AmountPreset[] {
return ((raw.predefinedDonationAmounts ?? []) as Raw[])
.map((p) => ({ amount: amountOf(p.price), label: formatMoney(p.price, currency), impact: p.description ?? "" }))
.filter((p) => p.amount > 0);
}
/** customAmountEnabled + customAmountOptions; a 0 or absent limit means no limit. */
export function customAmountOf(raw: Raw, currency: string): DonationOptions["customAmount"] {
const o: Raw = raw.customAmountOptions ?? {};
const min = amountOf(o.minimum) || null;
const max = amountOf(o.maximum) || null;
return {
enabled: raw.customAmountEnabled === true,
min,
max,
minLabel: min ? formatMoney(o.minimum, currency) : "",
maxLabel: max ? formatMoney(o.maximum, currency) : "",
};
}
/** donationFrequencies in API order, labelled; unknown values dropped; ONE_TIME when the list is empty. */
export function frequenciesOf(raw: Raw): FrequencyOption[] {
const list = ((raw.donationFrequencies ?? []) as string[]).filter((f): f is DonationFrequency => (FREQUENCIES as string[]).includes(f));
return (list.length ? list : ["ONE_TIME" as DonationFrequency]).map((value) => ({ value, label: FREQUENCY_LABELS[value] }));
}
export function optionsOf(raw: Raw, currency: string): DonationOptions {
return {
currency,
presets: presetsOf(raw, currency),
customAmount: customAmountOf(raw, currency),
frequencies: frequenciesOf(raw),
askCoverFee: raw.askDonorCoverFee === true,
feeRate: FEE_RATE,
commentsEnabled: raw.commentsEnabled === true,
commentMaxLength: NOTE_MAX,
};
}
// ---- DTO mappers -----------------------------------------------------------------------------------
export function toSummary(raw: Raw, metrics: Raw[] | null | undefined, currency: string, imgSrc: ImgSrc): CampaignSummary {
const status = statusOf(raw);
return {
id: rawId(raw),
name: raw.name ?? "",
status,
acceptsDonations: status === "collecting",
imageUrl: imgSrc(campaignImage(raw), 1200, 675),
goal: toGoal(raw.campaignGoal, metrics, currency),
};
}
export function toDetail(raw: Raw, metrics: Raw[] | null | undefined, currency: string, imgSrc: ImgSrc): CampaignDetail {
return { ...toSummary(raw, metrics, currency, imgSrc), options: optionsOf(raw, currency) };
}
// ---- the form's rules ------------------------------------------------------------------------------
export type DonationField = "amount" | "customAmount" | "frequency" | "note";
/** What the visitor has chosen so far — the store's editable half. */
export interface DonationSelection {
frequency: DonationFrequency | null;
/** The selected preset's amount (null in custom mode or before a choice). */
presetAmount: number | null;
/** "Other amount" is active. */
customMode: boolean;
/** The custom field's raw text; parsed on validate. */
customAmount: string;
coverFee: boolean;
note: string;
}
/**
* The widget's defaults: the first preset, the first frequency, cover-fee pre-checked when the
* owner asks for it, and custom mode forced when the campaign offers no presets.
*/
export function defaultSelection(options: DonationOptions): DonationSelection {
const customOnly = options.customAmount.enabled && options.presets.length === 0;
return {
frequency: options.frequencies[0]?.value ?? null,
presetAmount: customOnly ? null : options.presets[0]?.amount ?? null,
customMode: customOnly,
customAmount: "",
coverFee: options.askCoverFee,
note: "",
};
}
/** "1,250.50" / " 25 " → 1250.5; null when not a number. */
export function parseAmount(text: string): number | null {
const cleaned = text.replace(/[\s,]/g, "");
if (!cleaned) return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
const decimalsOf = (n: number): number => {
const s = String(n);
const dot = s.indexOf(".");
return dot === -1 ? 0 : s.length - dot - 1;
};
/** The amount the selection resolves to (custom text parsed in custom mode); null when there is none. */
export function selectedAmount(sel: DonationSelection): number | null {
return sel.customMode ? parseAmount(sel.customAmount) : sel.presetAmount;
}
/** The donor's fee at Wix's fixed 2.9%, rounded to cents; null for a non-positive amount. */
export function feeFor(amount: number | null): number | null {
if (amount == null || amount <= 0) return null;
return Math.round(amount * FEE_RATE * 100) / 100;
}
/** amount + fee when the owner asks and the donor agreed; the amount alone otherwise. */
export function totalFor(amount: number | null, coverFee: boolean, askCoverFee: boolean): number | null {
if (amount == null) return null;
const fee = askCoverFee && coverFee ? feeFor(amount) ?? 0 : 0;
return Math.round((amount + fee) * 100) / 100;
}
/** The widget's validation, field by field; {} when the selection is valid. */
export function validateDonation(options: DonationOptions, sel: DonationSelection): Partial<Record<DonationField, DonationErrorCode>> {
const errors: Partial<Record<DonationField, DonationErrorCode>> = {};
if (sel.customMode) {
const n = parseAmount(sel.customAmount);
const { min, max } = options.customAmount;
if (n == null || n <= 0) errors.customAmount = "MISSING_AMOUNT";
else if (decimalsOf(n) > 2) errors.customAmount = "TOO_MANY_DECIMALS";
else if (min != null && n < min) errors.customAmount = "BELOW_MIN_AMOUNT";
else if (max != null && n > max) errors.customAmount = "ABOVE_MAX_AMOUNT";
} else if (sel.presetAmount == null || sel.presetAmount <= 0) {
errors.amount = "MISSING_AMOUNT";
}
if (!sel.frequency) errors.frequency = "MISSING_FREQUENCY";
if (sel.note.length > options.commentMaxLength) errors.note = "NOTE_TOO_LONG";
return errors;
}
/** Visitor-facing text for a validation code, with the campaign's own limits. */
export function errorMessage(code: DonationErrorCode, options: DonationOptions): string {
switch (code) {
case "MISSING_AMOUNT":
return "Enter an amount.";
case "TOO_MANY_DECIMALS":
return "Use at most two decimal places.";
case "BELOW_MIN_AMOUNT":
return `The minimum donation is ${options.customAmount.minLabel || options.customAmount.min}.`;
case "ABOVE_MAX_AMOUNT":
return `The maximum donation is ${options.customAmount.maxLabel || options.customAmount.max}.`;
case "MISSING_FREQUENCY":
return "Choose how often to donate.";
case "NOTE_TOO_LONG":
return `Keep your note under ${options.commentMaxLength} characters.`;
}
}
/** "Donate $25" / "Donate $25.73 Monthly" / "Donate" when there is no amount or no known currency. */
export function donateLabel(total: number | null, frequency: DonationFrequency | null, currency: string): string {
const amount = formatAmount(total, currency);
const cadence = frequency && frequency !== "ONE_TIME" ? ` ${FREQUENCY_LABELS[frequency]}` : "";
return amount ? `Donate ${amount}${cadence}` : "Donate";
}
/** A valid selection → the checkout input; null when validation fails. */
export function toInput(options: DonationOptions, sel: DonationSelection): DonationInput | null {
if (Object.keys(validateDonation(options, sel)).length) return null;
const amount = selectedAmount(sel);
if (amount == null || !sel.frequency) return null;
return { amount, frequency: sel.frequency, coverFee: options.askCoverFee && sel.coverFee, note: sel.note.trim() };
}
// ---- cart -----------------------------------------------------------------------------------------
/**
* The one catalog item a donation cart carries. `amount` is a NUMBER and `frequency` the enum
* string; `donorCoveringFees` is sent only when the donor opted in (the Donations catalog plugin
* prices the line from these options — never a customLineItem, never a computed price).
*/
export function donationLineItem(campaignId: string, input: DonationInput): Raw {
if (!campaignId) throw new Error("A campaign id is required to donate.");
return {
quantity: 1,
catalogReference: {
appId: DONATIONS_APP_ID,
catalogItemId: campaignId,
options: { amount: input.amount, frequency: input.frequency, ...(input.coverFee ? { donorCoveringFees: true } : {}) },
},
};
}
/**
* Create Cart body (Cart V2): the single donation catalog item, the WEB channel on the cart's
* `source`, the donor note as the cart's `note`. In V2 the created cart IS the checkout — its id is
* the id the redirect session takes; there is no separate Create Checkout step.
*/
export function cartBody(campaignId: string, input: DonationInput): Raw {
return {
cart: { source: { channelType: "WEB" }, ...(input.note ? { note: input.note } : {}) },
catalogItems: [donationLineItem(campaignId, input)],
};
}
/**
* The cart id out of the Create Cart response — the id a V2 donation hands the redirect session
* (the cart is the checkout). The SDK returns a Cart directly (`_id`); REST wraps it as
* `{ cart: { id } }`.
*/
export function cartIdOf(res: Raw | null | undefined): string {
const id = res?._id ?? res?.id ?? res?.cart?._id ?? res?.cart?.id ?? "";
if (!id) throw new Error("Checkout couldn't start: no cart id returned.");
return String(id);
}
/** Where the hosted checkout returns to. Defaults are the Astro routes; a static site passes its file paths. */
export interface DonatePaths {
/** Success landing — Wix appends `?orderId=<eCom order id>`. Default `/donate/thank-you`. */
thankYou?: string;
/** Back here on abandon or interruption (NOT a success signal). Default `/donate/<campaignId>`. */
campaign?: string;
}
/**
* The Create Redirect Session body for a donation checkout. `origin` is the published https host
* (window.location.origin in a browser) — an http or server-derived origin isn't on the OAuth app's
* allowed domains and 403s the return; no callbacks when unknown (SSR).
*/
export function redirectBody(checkoutId: string, campaignId: string, origin: string, paths: DonatePaths = {}): Raw {
if (!checkoutId) throw new Error("A checkout id is required to start the hosted checkout.");
const callbacks = origin
? {
postFlowUrl: `${origin}${paths.campaign ?? `/donate/${campaignId}`}`,
thankYouPageUrl: `${origin}${paths.thankYou ?? "/donate/thank-you"}`,
}
: {};
return { ecomCheckout: { checkoutId }, callbacks };
}
/** The hosted checkout URL out of a redirect-session response; throws when there is none. */
export function redirectUrl(session: Raw | null | undefined): string {
const url = session?.redirectSession?.fullUrl;
if (!url) throw new Error("Checkout couldn't start — please try again.");
return url;
}
/** A checkout failure as the visitor should read it; the raw message is kept when no rule applies. */
export function checkoutError(e: unknown): Error {
const msg = e instanceof Error ? e.message : String(e);
if (/premium|payment method|payments? (is|are) not (set up|enabled)|PAYMENT_METHOD/i.test(msg)) {
return new Error("Donations aren't switched on yet — the site owner needs a premium plan and a connected payment method.");
}
return e instanceof Error ? e : new Error(msg || "Checkout couldn't start — please try again.");
}
// ---- the receipt ----------------------------------------------------------------------------------
/**
* The order id the hosted checkout appends to thankYouPageUrl: the redirect-session contract
* spells it `orderId`; the Wix widget's own success URL uses `orderid`. Both are read.
*/
export function orderIdFromSearch(search: string): string {
const q = new URLSearchParams(search);
return q.get("orderId") ?? q.get("orderid") ?? "";
}
/**
* An eCom order → the receipt: the donor's name from billingInfo.contactDetails (recipientInfo only
* when billing lacks a complete name), the order total's formatted amount, paid only on PAID.
*/
export function toReceipt(order: Raw): DonationReceipt {
const complete = (c: Raw | undefined): c is Raw => !!(c?.firstName && c?.lastName);
const billing: Raw | undefined = order.billingInfo?.contactDetails;
const recipient: Raw | undefined = order.recipientInfo?.contactDetails;
const contact = complete(billing) ? billing : complete(recipient) ? recipient : billing ?? recipient ?? {};
const total: Raw | undefined = order.priceSummary?.total;
return {
orderNumber: order.number != null ? String(order.number) : "",
donorFirstName: contact.firstName ?? "",
donorLastName: contact.lastName ?? "",
amount: formatMoney(total, order.currency ?? ""),
paid: order.paymentStatus === "PAID",
campaignId: order.lineItems?.[0]?.catalogReference?.catalogItemId ?? "",
};
}
SHA-256: 1f567add0355d538d2636b11e8507e43617a4e29eab5970ac448b6f870ba954b