Skip to content
This repository has been archived by the owner on Jun 7, 2024. It is now read-only.

Commit

Permalink
currency conversions
Browse files Browse the repository at this point in the history
  • Loading branch information
NextFire committed Mar 26, 2024
1 parent 90b083b commit c05ab8e
Show file tree
Hide file tree
Showing 10 changed files with 135 additions and 47 deletions.
17 changes: 13 additions & 4 deletions components/expenses/Card.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@ const concerns = computed(() => {
});
const expenseAmount = computed(() => dinero(JSON.parse(props.expense.amount)));
const expenseCurrency = computed(
() => toSnapshot(expenseAmount.value).currency
const expenseOgAmount = computed(() =>
props.expense.originalAmount
? dinero(JSON.parse(props.expense.originalAmount))
: undefined
);
const impact = computed(() => {
const idx = props.expense.shares.findIndex(
(s) => s.memberId === props.currentMember
);
const selfShare =
idx !== -1 ? props.resolvedShares[idx] : zero(expenseCurrency.value);
idx !== -1
? props.resolvedShares[idx]
: zero(toSnapshot(expenseAmount.value).currency);
if (props.expense.authorId === props.currentMember) {
return subtract(expenseAmount.value, selfShare);
} else {
Expand Down Expand Up @@ -73,7 +77,12 @@ const expenseFormStore = useExpenseFormStore();
<div class="card-body gap-y-0">
<h2 class="card-title text-primary">
<span class="flex-1">{{ expense.title }}</span>
<span>{{ toString(expenseAmount) }}</span>
<div class="flex flex-col text-right">
<span>{{ toString(expenseAmount) }}</span>
<span v-if="expenseOgAmount" class="text-xs">
({{ toString(expenseOgAmount) }})
</span>
</div>
</h2>

<p class="flex">
Expand Down
26 changes: 22 additions & 4 deletions components/expenses/View.client.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
<script setup lang="ts">
import { TrashIcon } from "@heroicons/vue/24/solid";
import { add, dinero } from "dinero.js";
import {
add,
dinero,
haveSameCurrency,
type Currency,
type Dinero,
} from "dinero.js";
const props = defineProps<{ count: CountData; currentMember?: number }>();
Expand Down Expand Up @@ -51,6 +57,17 @@ const expenseFormStore = useExpenseFormStore();
const selectedExpense = ref<ExpenseData>();
const submit = async () => {
const countCurrency = JSON.parse(props.count.currency) as Currency<number>;
const date = new Date(expenseFormStore.date!);
let amount: Dinero<number> = expenseFormStore.amount!;
let originalAmount: Dinero<number> | undefined = undefined;
if (!haveSameCurrency([amount, zero(countCurrency)])) {
originalAmount = amount;
amount = await convertTo(amount, countCurrency, date);
}
const res = await $fetch(`/api/expenses/${selectedExpense.value!.id}`, {
method: "PUT",
headers: {
Expand All @@ -60,8 +77,9 @@ const submit = async () => {
countId: props.count.id,
title: expenseFormStore.title,
description: expenseFormStore.description,
amount: expenseFormStore.amount,
date: new Date(expenseFormStore.date!).toISOString(),
amount,
originalAmount,
date: date.toISOString(),
authorId: expenseFormStore.author,
// FIXME: this is really ugly
shares: Object.entries(expenseFormStore.shares)
Expand Down Expand Up @@ -149,7 +167,7 @@ const submitDelete = async () => {
</div>

<div class="cursor-default">
<NewExpenseModal :count="count" />
<NewExpenseModal :count="count" :current-member="currentMember" />
</div>

<div class="card card-compact bg-base-200 text-xs cursor-default">
Expand Down
36 changes: 17 additions & 19 deletions components/new-expense/Infos.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { toSnapshot, type Dinero, type Currency } from "dinero.js";
import { toSnapshot, type Dinero, type Currency, equal } from "dinero.js";
const props = defineProps<{ count: CountData }>();
Expand All @@ -9,17 +9,22 @@ const amount = defineModel<Dinero<number>>("amount");
const date = defineModel<string>("date");
const author = defineModel<number>("author");
const amountValue = ref(0);
const amountCurrency = ref<Currency<number>>(
amount.value
? toSnapshot(amount.value).currency
: JSON.parse(props.count.currency)
);
const amountValue = ref<number>();
const amountCurrency = ref<Currency<number>>(JSON.parse(props.count.currency));
watchEffect(() => {
if (amount.value) {
amountValue.value = toFloat(amount.value);
amountCurrency.value = toSnapshot(amount.value).currency;
amountValue.value = amount.value ? toFloat(amount.value) : undefined;
amountCurrency.value = amount.value
? toSnapshot(amount.value).currency
: JSON.parse(props.count.currency);
});
watch([amountValue, amountCurrency], ([val, curr]) => {
if (val !== undefined) {
const newAmount = fromFloat(val, curr);
if (!amount.value || !equal(newAmount, amount.value)) {
amount.value = newAmount;
}
}
});
</script>
Expand All @@ -42,18 +47,11 @@ watchEffect(() => {
<div class="flex items-center gap-x-2">
<input
type="number"
v-model="amountValue"
placeholder="Amount"
class="input input-bordered w-full"
:value="amountValue"
@input="
(ev) => {
const amount = (ev.target as HTMLInputElement).valueAsNumber;
amountValue = amount;
}
"
@focusout="amount = fromFloat(amountValue, amountCurrency)"
/>
<select v-model="amountCurrency" class="select select-bordered" disabled>
<select v-model="amountCurrency" class="select select-bordered">
<option
v-for="[code, curr] in Object.entries(currencyRecord)"
:key="code"
Expand Down
20 changes: 17 additions & 3 deletions components/new-expense/Modal.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { PlusIcon } from "@heroicons/vue/24/solid";
import { haveSameCurrency, type Currency, type Dinero } from "dinero.js";
const props = defineProps<{ count: CountData }>();
const props = defineProps<{ count: CountData; currentMember?: number }>();
const expenseFormStore = useExpenseFormStore();
const modalRef = ref<HTMLDialogElement | null>(null);
const submit = async () => {
const countCurrency = JSON.parse(props.count.currency) as Currency<number>;
const date = new Date(expenseFormStore.date!);
let amount: Dinero<number> = expenseFormStore.amount!;
let originalAmount: Dinero<number> | undefined = undefined;
if (!haveSameCurrency([amount, zero(countCurrency)])) {
originalAmount = amount;
amount = await convertTo(amount, countCurrency, date);
}
const res = await $fetch("/api/expenses", {
method: "POST",
headers: {
Expand All @@ -17,8 +29,9 @@ const submit = async () => {
countId: props.count.id,
title: expenseFormStore.title,
description: expenseFormStore.description,
amount: expenseFormStore.amount,
date: new Date(expenseFormStore.date!).toISOString(),
amount,
originalAmount,
date: date.toISOString(),
authorId: expenseFormStore.author,
// FIXME: this is really ugly
shares: Object.entries(expenseFormStore.shares)
Expand All @@ -43,6 +56,7 @@ const submit = async () => {
@click="
() => {
expenseFormStore.$reset();
expenseFormStore.author = currentMember;
for (const m of count.members ?? []) {
expenseFormStore.shares[m.id] = { fraction: 1 };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "originalAmount" TEXT;
21 changes: 11 additions & 10 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,17 @@ model Member {
}

model Expense {
id Int @id @default(autoincrement())
title String
description String?
amount String
date DateTime
author Member @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
shares Share[]
count Count @relation(fields: [countId], references: [id], onDelete: Cascade)
countId String
id Int @id @default(autoincrement())
title String
description String?
amount String
originalAmount String?
date DateTime
author Member @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
shares Share[]
count Count @relation(fields: [countId], references: [id], onDelete: Cascade)
countId String
}

model Share {
Expand Down
13 changes: 11 additions & 2 deletions server/api/expenses/[id].put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ export default defineEventHandler(async (event) => {
const paramId = getRouterParam(event, "id");
const id = parseInt(paramId!);

const { countId, title, description, amount, date, authorId, shares } =
await readBody(event);
const {
countId,
title,
description,
amount,
originalAmount,
date,
authorId,
shares,
} = await readBody(event);

const data: Prisma.ExpenseUpdateInput = {
count: { connect: { id: countId } },
title,
description,
amount: JSON.stringify(amount),
originalAmount: JSON.stringify(originalAmount),
date,
author: { connect: { id: authorId } },
shares: {
Expand Down
13 changes: 11 additions & 2 deletions server/api/expenses/index.post.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { Prisma } from "@prisma/client";

export default defineEventHandler(async (event) => {
const { countId, title, description, amount, date, authorId, shares } =
await readBody(event);
const {
countId,
title,
description,
amount,
originalAmount,
date,
authorId,
shares,
} = await readBody(event);
const data: Prisma.ExpenseCreateInput = {
count: { connect: { id: countId } },
title,
description,
amount: JSON.stringify(amount),
originalAmount: JSON.stringify(originalAmount),
date,
author: { connect: { id: authorId } },
shares: {
Expand Down
2 changes: 1 addition & 1 deletion stores/useExpenseFormStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const useExpenseFormStore = defineStore("expense", () => {
function load(expense: ExpenseData) {
title.value = expense.title;
description.value = expense.description ?? undefined;
amount.value = dinero(JSON.parse(expense.amount));
amount.value = dinero(JSON.parse(expense.originalAmount ?? expense.amount));
date.value = new Date(expense.date).toISOString().split("T")[0];
author.value = expense.authorId;
expense.shares.forEach((share) => {
Expand Down
32 changes: 30 additions & 2 deletions utils/dinero.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import * as currencies from "@dinero.js/currencies";
import { dinero, toDecimal, type Currency, type Dinero } from "dinero.js";
import {
convert,
dinero,
toDecimal,
toSnapshot,
transformScale,
type Currency,
type Dinero,
type Rates,
} from "dinero.js";

export function zero(currency: Currency<number>): Dinero<number> {
return dinero({ amount: 0, currency });
Expand All @@ -20,11 +29,30 @@ export function toFloat(amount: Dinero<number>): number {

export function toString(amount: Dinero<number>): string {
return toDecimal(
amount,
transformScale(amount, toSnapshot(amount).currency.exponent),
({ value, currency }) => `${currency.code} ${value}`
);
}

export const currencyRecord = Object.fromEntries(
Object.values(currencies).map((currency) => [currency.code, currency])
);

export async function convertTo(
amount: Dinero<number>,
currency: Currency<number>,
date: Date
): Promise<Dinero<number>> {
const ogCurrency = toSnapshot(amount).currency;
const code = ogCurrency.code.toLowerCase();
const dateStr = date.toLocaleDateString("en-CA"); // YYYY-MM-DD
const data = await $fetch<any>(
`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@${dateStr}/v1/currencies/${code}.min.json`
);
const rate = fromFloat(data[code][currency.code.toLowerCase()], currency);
const snap = toSnapshot(rate);
const rates: Rates<number> = {
[currency.code]: { amount: snap.amount, scale: snap.scale },
};
return convert(amount, currency, rates);
}

0 comments on commit c05ab8e

Please sign in to comment.