Skip to content

Commit

Permalink
Add support for variable currency decimal position
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardozgz committed Jan 9, 2025
1 parent 2595bef commit f40a542
Show file tree
Hide file tree
Showing 11 changed files with 147 additions and 43 deletions.
3 changes: 2 additions & 1 deletion apps/website/src/@types/resources.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,7 @@ interface Resources {
"userId": "Discord user ID",
"amount": "Amount",
"currency": "Currency",
"currencyDecimals": "Currency decimals",
"date": "Date",
"note": "Note",
"anonymous": "Anonymous",
Expand All @@ -572,7 +573,7 @@ interface Resources {
},
"status": {
"statusUnavailable": "Status unavailable",
"child": "Child {{index}}",
"child": "Child #{{index}}",
"serverInformation": {
"title": "Server information",
"hostname": "Name",
Expand Down
12 changes: 8 additions & 4 deletions apps/website/src/app/admin/donations/Donation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";

import { CurrencyUtils } from "@mc/common/currencyUtils";
import { Card, CardContent, CardHeader } from "@mc/ui/card";

import type { RouterOutputs } from "~/trpc/react";
Expand All @@ -11,12 +12,10 @@ export function Donation(
donation: RouterOutputs["donor"]["geAllDonations"][number],
) {
const { i18n } = useTranslation();

const { amount, currency, currencyDecimals } = donation;
const dateFormatter = Intl.DateTimeFormat(i18n.language, {
dateStyle: "short",
});
const currencyFormatter = (currency: string) =>
Intl.NumberFormat(i18n.language, { currency, style: "currency" });

return (
<Link href={Routes.ManageDonations(donation.id)}>
Expand All @@ -27,7 +26,12 @@ export function Donation(
<div className="flex flex-col items-end gap-2 text-muted-foreground">
<div>{dateFormatter.format(donation.date)}</div>
<div className="">
{currencyFormatter(donation.currency).format(donation.amount)}
{CurrencyUtils.format(
i18n.language,
amount,
currency,
currencyDecimals,
)}
</div>
</div>
</CardHeader>
Expand Down
23 changes: 21 additions & 2 deletions apps/website/src/app/admin/donations/DonationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ export function DonationForm<
min={0}
required
type="number"
value={value.amount}
value={Number(value.amount)}
onChange={(e) =>
onChange({ ...value, amount: e.target.valueAsNumber })
onChange({
...value,
amount: BigInt(e.target.value.replaceAll(/[^0-9]+/g, "")),
})
}
/>
</Label>
Expand All @@ -75,6 +78,22 @@ export function DonationForm<
/>
</Label>
</div>
<div className="flex-shrink">
<Label>
{t("pages.admin.donations.form.currencyDecimals")}
<Input
value={value.currencyDecimals}
onChange={(e) =>
onChange({
...value,
currencyDecimals: Number(
e.target.value.replaceAll(/[^0-9]+/g, ""),
),
})
}
/>
</Label>
</div>
</div>
<Label>
{t("pages.admin.donations.form.note")}
Expand Down
3 changes: 2 additions & 1 deletion apps/website/src/app/admin/donations/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ export default function Page() {
RouterInputs["donor"]["registerDonation"]
>({
userId: "",
amount: 0,
amount: 0n,
note: "",
currency: "EUR",
currencyDecimals: 2,
anonymous: false,
date: new Date(),
});
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/app/admin/donations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function Page() {
const donations = api.donor.geAllDonations.useQuery();
const total = donations.data?.length ?? "???";
const totalValue = useMemo(
() => donations.data?.reduce((acc, curr) => acc + curr.value, 0) ?? "???",
() => donations.data?.reduce((acc, curr) => acc + curr.value, 0n) ?? "???",
[donations.data],
);

Expand Down
40 changes: 22 additions & 18 deletions apps/website/src/app/donors/Donor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useTranslation } from "react-i18next";

import { CurrencyUtils } from "@mc/common/currencyUtils";
import {
Dialog,
DialogContent,
Expand All @@ -27,8 +28,6 @@ export function Donor({
const dateFormatter = Intl.DateTimeFormat(i18n.language, {
dateStyle: "short",
});
const currencyFormatter = (currency: string) =>
Intl.NumberFormat(i18n.language, { currency, style: "currency" });

return (
<Dialog>
Expand All @@ -52,24 +51,29 @@ export function Donor({
</DialogTitle>
</DialogHeader>
<div className="mt-4 flex flex-col gap-2">
{donations.map((donation, i) => (
<>
<div className="">
<div className="flex justify-between text-muted-foreground">
<div>{dateFormatter.format(donation.date)}</div>
<div className="">
{currencyFormatter(donation.currency).format(
donation.amount,
)}
{donations.map(
({ date, amount, currency, currencyDecimals, note }, i) => (
<>
<div className="">
<div className="flex justify-between text-muted-foreground">
<div>{dateFormatter.format(date)}</div>
<div className="">
{CurrencyUtils.format(
i18n.language,
amount,
currency,
currencyDecimals,
)}
</div>
</div>
<div className="my-2">{note}</div>
</div>
<div className="my-2">{donation.note}</div>
</div>
{i != donations.length - 1 && (
<Separator className="my-4 bg-accent-foreground" />
)}
</>
))}
{i != donations.length - 1 && (
<Separator className="my-4 bg-accent-foreground" />
)}
</>
),
)}
</div>
</DialogContent>
</Dialog>
Expand Down
12 changes: 6 additions & 6 deletions apps/website/src/app/donors/Donors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,25 @@ export function Donors() {

return donors.sort(
(a, b) =>
b.donor.donations.reduce((a, c) => c.amount + a, 0) -
a.donor.donations.reduce((a, c) => c.amount + a, 0),
b.donor.donations.reduce((a, c) => Number(c.value) + a, 0) -
a.donor.donations.reduce((a, c) => Number(c.value) + a, 0),
);
}, [donorsQuery.data]);

const totalDonated = useMemo(
() =>
donors.reduce(
(sum, { donor }) =>
sum + donor.donations.reduce((a, c) => c.amount + a, 0),
sum + donor.donations.reduce((a, c) => Number(c.value) + a, 0),
0,
),
[donors],
);
const processedDonors = useMemo(() => {
const processedDonors = structuredClone(donors);

const donationBubbleRadius = (amount: number) => {
const radius = (amount / totalDonated) * (totalDonated * 4);
const donationBubbleRadius = (value: bigint) => {
const radius = (Number(value) / totalDonated) * (totalDonated * 4);
if (radius > 0.15 * screenSize[0]) {
return screenSize[0] * 0.15;
}
Expand All @@ -71,7 +71,7 @@ export function Donors() {

processedDonors.forEach((bubble) => {
bubble.radius = donationBubbleRadius(
bubble.donor.donations.reduce((a, c) => c.amount + a, 0),
bubble.donor.donations.reduce((a, c) => c.value + a, 0n),
);
});

Expand Down
1 change: 1 addition & 0 deletions apps/website/src/i18n/locales/en-US/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@
"userId": "Discord user ID",
"amount": "Amount",
"currency": "Currency",
"currencyDecimals": "Currency decimals",
"date": "Date",
"note": "Note",
"anonymous": "Anonymous",
Expand Down
6 changes: 4 additions & 2 deletions apps/website/src/server/api/routers/donor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ export const donorRouter = createTRPCRouter({
note: z.string(),
anonymous: z.boolean(),
date: z.date(),
amount: z.number(),
amount: z.bigint(),
currency: z.string(),
currencyDecimals: z.number(),
}),
)
.mutation(async ({ ctx: { authUser }, input }) => {
Expand Down Expand Up @@ -139,8 +140,9 @@ export const donorRouter = createTRPCRouter({
note: z.string().optional(),
anonymous: z.boolean().optional(),
date: z.date().optional(),
amount: z.number().optional(),
amount: z.bigint().optional(),
currency: z.string().optional(),
currencyDecimals: z.number().optional(),
}),
)
.mutation(async ({ ctx: { authUser }, input }) => {
Expand Down
71 changes: 71 additions & 0 deletions packages/common/src/currencyUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "assert";

export class CurrencyUtils {
/**
* Converts a string (e.g., "10.44") or BigInt to a Number.
* @param {string | bigint} value - The monetary value as a string or BigInt.
* @param {number} decimals - Currency decimals.
* @returns {number} - The monetary value as a Number.
*/
static toNumber(value: string | bigint, decimals = 2): number {
if (typeof value === "string") {
return parseFloat(value);
}
if (typeof value === "bigint") {
return Number(value) / Math.pow(10, decimals);
}
throw new TypeError("Invalid type for conversion to Number");
}

/**
* Converts a string (e.g., "10.44") to a BigInt representing the smallest unit.
* @param {string} value - The monetary value as a string.
* @param {number} decimals - Currency decimals.
* @returns {bigint} - The amount in the smallest unit as a BigInt.
*/
static toBigInt(value: string, decimals = 2): bigint {
const [whole, fraction = "0"] = value.split(".");
assert(whole);
const fractionPadded = fraction.padEnd(decimals, "0"); // Ensure required decimals
return (
BigInt(whole) * BigInt(Math.pow(10, decimals)) +
BigInt(fractionPadded.slice(0, decimals))
);
}

/**
* Converts a BigInt to a string with the appropriate decimal places.
* @param {bigint} value - The amount in the smallest unit as a BigInt.
* @param {number} decimals - Currency decimals.
* @returns {string} - The monetary value as a string.
*/
static toString(value: bigint, decimals = 2): string {
const isNegative = value < 0n;
const absoluteValue = isNegative ? -value : value;

const divisor = BigInt(Math.pow(10, decimals));
const whole = absoluteValue / divisor;
const fraction = absoluteValue % divisor;

const result = `${whole}.${fraction.toString().padStart(decimals, "0")}`;
return isNegative ? `-${result}` : result;
}

static format(
locale: string,
amount: bigint,
currency: string,
currencyDecimals: number,
) {
// Convert the amount to a floating-point number with appropriate precision
const amountInUnits = Number(amount) / Math.pow(10, currencyDecimals);

// Handle the case where the value has many decimal places for small units (e.g., BTC, ETH)
return Intl.NumberFormat(locale, {
currency,
style: "currency",
minimumFractionDigits: Math.min(2, currencyDecimals), // Ensure minimum fraction digits
maximumFractionDigits: currencyDecimals, // Format using the given currency decimals
}).format(amountInUnits);
}
}
17 changes: 9 additions & 8 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ type DemoLink {
}

model Donation {
id String @id @default(auto()) @map("_id") @db.ObjectId
note String
anonymous Boolean
date DateTime
amount Float
currency String
user User? @relation(fields: [userId], references: [discordUserId], onDelete: Cascade)
userId String
id String @id @default(auto()) @map("_id") @db.ObjectId
note String
anonymous Boolean
date DateTime
amount BigInt
currency String
currencyDecimals Int
user User? @relation(fields: [userId], references: [discordUserId], onDelete: Cascade)
userId String
}

0 comments on commit f40a542

Please sign in to comment.