Merge pull request #226 from 67P/feature/currencies

Use historic BTC rate for expense items; allow BTC as currency
This commit was merged in pull request #226.
This commit is contained in:
Râu Cao
2025-02-16 15:50:25 +01:00
committed by GitHub
19 changed files with 143 additions and 63 deletions
+1
View File
@@ -0,0 +1 @@
16
+2 -1
View File
@@ -18,7 +18,8 @@ export default class AddExpenseItemComponent extends Component {
currencies = [ currencies = [
{ code: 'EUR' }, { code: 'EUR' },
{ code: 'USD' } { code: 'USD' },
{ code: 'BTC' }
]; ];
get isValidAmount () { get isValidAmount () {
+31 -23
View File
@@ -5,7 +5,9 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { A } from '@ember/array'; import { A } from '@ember/array';
import { scheduleOnce } from '@ember/runloop'; import { scheduleOnce } from '@ember/runloop';
import { btcToSats, satsToBtc } from 'kredits-web/utils/btc-conversions';
import isValidAmount from 'kredits-web/utils/is-valid-amount'; import isValidAmount from 'kredits-web/utils/is-valid-amount';
import isoDateIsToday from 'kredits-web/utils/iso-date-is-today';
import readFileContent from 'kredits-web/utils/read-file-content'; import readFileContent from 'kredits-web/utils/read-file-content';
import config from 'kredits-web/config/environment'; import config from 'kredits-web/config/environment';
@@ -73,22 +75,6 @@ export default class AddReimbursementComponent extends Component {
anchor.scrollIntoView(); anchor.scrollIntoView();
} }
updateTotalAmountFromFiat() {
let btcAmount = 0;
if (this.exchangeRates.btceur > 0 && this.totalEUR > 0) {
btcAmount += (this.totalEUR / this.exchangeRates.btceur);
}
if (this.exchangeRates.btcusd > 0 && this.totalUSD > 0) {
btcAmount += (this.totalUSD / this.exchangeRates.btcusd);
}
if (this.totalUSD === 0 && this.totalEUR === 0) {
btcAmount = 0;
}
this.total = btcAmount.toFixed(8);
}
// TODO use ember-concurrency here // TODO use ember-concurrency here
// https://github.com/67P/kredits-web/pull/209#discussion_r1064234421 // https://github.com/67P/kredits-web/pull/209#discussion_r1064234421
@action @action
@@ -118,16 +104,38 @@ export default class AddReimbursementComponent extends Component {
} }
@action @action
addExpenseItem (expenseItem) { async addExpenseItem (expense) {
this.expenses.pushObject(expenseItem); let totalBTC = parseFloat(this.total);
this.updateTotalAmountFromFiat();
if (expense.currency === "BTC") {
expense.amountSats = btcToSats(expense.amount);
totalBTC += expense.amount;
} else {
let amountSats;
if (isoDateIsToday(expense.date)) {
amountSats = btcToSats(expense.amount / this.exchangeRates[expense.currency]);
} else {
const rates = await this.exchangeRates.fetchHistoricRates(expense.date);
amountSats = btcToSats(expense.amount / rates[expense.currency]);
}
expense.amountSats = amountSats;
totalBTC += satsToBtc(amountSats);
}
console.debug("Adding expense:", expense);
this.total = totalBTC.toFixed(8);
this.expenses.pushObject(expense);
this.expenseFormVisible = false; this.expenseFormVisible = false;
} }
@action @action
removeExpenseItem (expenseItem) { async removeExpenseItem (expense) {
this.expenses.removeObject(expenseItem); let totalBTC = parseFloat(this.total);
this.updateTotalAmountFromFiat(); let amountBTC = satsToBtc(expense.amountSats);
totalBTC = totalBTC - amountBTC;
this.total = totalBTC.toFixed(8);
this.expenses.removeObject(expense);
if (this.expenses.length === 0) { if (this.expenses.length === 0) {
this.expenseFormVisible = true; this.expenseFormVisible = true;
@@ -143,7 +151,7 @@ export default class AddReimbursementComponent extends Component {
const contributor = this.contributors.findBy('id', this.contributorId); const contributor = this.contributors.findBy('id', this.contributorId);
const attributes = { const attributes = {
amount: parseInt(parseFloat(this.total) * 100000000), // convert to sats amount: btcToSats(this.total),
token: config.tokens['BTC'], token: config.tokens['BTC'],
recipientId: this.contributorId, recipientId: this.contributorId,
title: `Expenses covered by ${contributor.name}`, title: `Expenses covered by ${contributor.name}`,
+1 -1
View File
@@ -5,7 +5,7 @@
<span class="date">{{fmt-date-localized expense.date}}:</span> <span class="date">{{fmt-date-localized expense.date}}:</span>
<span class="title">{{expense.title}}</span> <span class="title">{{expense.title}}</span>
</h4> </h4>
<div class="amount"> <div class="amount" title="{{fmt-amount-sats-title expense.amountSats}}">
{{fmt-fiat-currency expense.amount expense.currency}} {{fmt-fiat-currency expense.amount expense.currency}}
</div> </div>
<p class="description"> <p class="description">
@@ -1,7 +1,7 @@
<li data-reimbursement-id={{@reimbursement.id}} <li data-reimbursement-id={{@reimbursement.id}}
class="{{item-status @reimbursement}}" class="{{item-status @reimbursement}}">
role="button" {{on "click" this.toggleExpenseDetails}}> <p class="meta cursor-pointer" role="button"
<p class="meta"> {{on "click" this.toggleExpenseDetails}}>
<span class="recipient"> <span class="recipient">
<UserAvatar @contributor={{@reimbursement.contributor}} /> <UserAvatar @contributor={{@reimbursement.contributor}} />
</span> </span>
+11
View File
@@ -0,0 +1,11 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtAmountSatsTitle(params) {
const amountSats = params[0];
if (typeof amountSats === 'number' && amountSats > 0) {
return `${amountSats} sats`;
} else {
return '';
}
});
+7 -3
View File
@@ -2,10 +2,14 @@ import { helper } from '@ember/component/helper';
export default helper(function fmtFiatCurrency(params) { export default helper(function fmtFiatCurrency(params) {
const lang = navigator.language || navigator.userLanguage; const lang = navigator.language || navigator.userLanguage;
const value = params[0];
const currency = params[1] || 'EUR';
if (currency === 'BTC') return `BTC ${value}`;
const formatter = new Intl.NumberFormat(lang, { const formatter = new Intl.NumberFormat(lang, {
style: 'currency', style: 'currency', currency, currencyDisplay: 'code'
currency: params[1] || 'EUR',
currencyDisplay: 'code'
}) })
return formatter.format(params[0]); return formatter.format(params[0]);
}); });
+1 -1
View File
@@ -42,7 +42,7 @@ export default class CommunityFundsService extends Service {
// Format and round the approximate USD value // Format and round the approximate USD value
const lang = navigator.language || navigator.userLanguage; const lang = navigator.language || navigator.userLanguage;
const balanceUSD = (res.confirmed_balance / 100000000) * this.exchangeRates.btcusd; const balanceUSD = (res.confirmed_balance / 100000000) * this.exchangeRates.USD;
res.balanceUSD = Math.round(balanceUSD).toLocaleString(lang); res.balanceUSD = Math.round(balanceUSD).toLocaleString(lang);
this.balances.pushObject({ this.balances.pushObject({
+23 -5
View File
@@ -4,6 +4,7 @@ import config from 'kredits-web/config/environment';
// Need to go through proxy for CORS headers // Need to go through proxy for CORS headers
const bitstampBaseUrl = `${config.corsProxy}https://www.bitstamp.net/api/v2`; const bitstampBaseUrl = `${config.corsProxy}https://www.bitstamp.net/api/v2`;
const kosmosBtcPriceBaseUrl = "https://storage.kosmos.org/kosmos/public/btc-price";
async function fetchFromBitstamp(currencyPair) { async function fetchFromBitstamp(currencyPair) {
try { try {
@@ -16,11 +17,12 @@ async function fetchFromBitstamp(currencyPair) {
} }
export default class ExchangeRatesService extends Service { export default class ExchangeRatesService extends Service {
@tracked btceur = 0; @tracked EUR = 0;
@tracked btcusd = 0; @tracked USD = 0;
@tracked historic = {};
get exchangeRatesLoaded () { get exchangeRatesLoaded () {
return (this.btceur !== 0) && (this.btcusd !== 0); return (this.EUR !== 0) && (this.USD !== 0);
} }
async fetchRates (source='bitstamp') { async fetchRates (source='bitstamp') {
@@ -28,8 +30,24 @@ export default class ExchangeRatesService extends Service {
switch(source) { switch(source) {
case 'bitstamp': case 'bitstamp':
this.btceur = await fetchFromBitstamp('btceur'); this.EUR = await fetchFromBitstamp('btceur');
this.btcusd = await fetchFromBitstamp('btcusd'); this.USD = await fetchFromBitstamp('btcusd');
}
}
async fetchHistoricRates (isoDate) {
if (typeof this.historic[isoDate] === "object") {
return this.historic[isoDate];
} else {
const url = `${kosmosBtcPriceBaseUrl}/${isoDate}`;
try {
const rates = await fetch(url).then(res => res.json());
this.historic[isoDate] = rates;
return rates;
} catch(e) {
console.error(e);
Promise.reject(e);
}
} }
} }
} }
+4
View File
@@ -45,6 +45,10 @@ a {
color: $primary-color; color: $primary-color;
} }
.cursor-pointer {
cursor: pointer;
}
section { section {
h2 { h2 {
font-size: 1.5rem; font-size: 1.5rem;
+11
View File
@@ -0,0 +1,11 @@
export function floatToBtc(number) {
return Number(number.toFixed(8))
}
export function btcToSats(btc) {
return Math.round(btc * 100_000_000);
}
export function satsToBtc(sats) {
return Math.round((sats / 100_000_000) * 100_000_000) / 100_000_000;
}
+4
View File
@@ -0,0 +1,4 @@
export default function(value) {
const today = new Date().toISOString().split('T')[0];
return value === today;
}
+2 -2
View File
@@ -76,10 +76,10 @@ module.exports = function(environment) {
ENV.githubSignupUrl = 'http://localhost:8888/kredits/signup/github'; ENV.githubSignupUrl = 'http://localhost:8888/kredits/signup/github';
ENV.ipfs = { ENV.ipfs = {
host: 'localhost', host: '10.1.1.198',
port: '5001', port: '5001',
protocol: 'http', protocol: 'http',
gatewayUrl: 'http://localhost:8080/ipfs' gatewayUrl: 'http://10.1.1.198:9090/ipfs'
}; };
// Uncomment if you want to use local akkounts // Uncomment if you want to use local akkounts
+16 -13
View File
@@ -14,7 +14,7 @@
"@ember/optional-features": "^1.3.0", "@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0", "@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0", "@glimmer/tracking": "^1.0.0",
"@kredits/contracts": "^7.3.0", "@kredits/contracts": "^7.5.0",
"babel-preset-es2015": "^6.22.0", "babel-preset-es2015": "^6.22.0",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-auto-import": "^1.12.1", "ember-auto-import": "^1.12.1",
@@ -3062,17 +3062,18 @@
} }
}, },
"node_modules/@kosmos/schemas": { "node_modules/@kosmos/schemas": {
"version": "3.1.0", "version": "3.2.0",
"dev": true, "resolved": "https://registry.npmjs.org/@kosmos/schemas/-/schemas-3.2.0.tgz",
"license": "MIT" "integrity": "sha512-GyWXmWcATcakmEdPOpafdckBgWFqcpSh+tB8nm11VGgtgA94pfoFhKZ3t88WELItKfuP/GFHgMiZmdMB3bSkdQ==",
"dev": true
}, },
"node_modules/@kredits/contracts": { "node_modules/@kredits/contracts": {
"version": "7.3.0", "version": "7.5.0",
"resolved": "https://registry.npmjs.org/@kredits/contracts/-/contracts-7.3.0.tgz", "resolved": "https://registry.npmjs.org/@kredits/contracts/-/contracts-7.5.0.tgz",
"integrity": "sha512-7Bo2LFEqGIK39G54AStas1RWouBOp+r7Vcv2vP1uLbY01Usea8SHvd2OjLP6nFgsQa0n/rto9oremHv5IAylrQ==", "integrity": "sha512-4Jw6lXn9DfZiTnMtWHwGLS5zhxEJZ2ANfAgcr7tZxqRl7Y0ijpoWvPCA+LkSGqRgKspyiw9+7vSVTtyvAyQbPQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@kosmos/schemas": "^3.1.0", "@kosmos/schemas": "^3.2.0",
"ethers": "^5.4.7", "ethers": "^5.4.7",
"ipfs-http-client": "^56.0.3", "ipfs-http-client": "^56.0.3",
"multihashes": "^4.0.3", "multihashes": "^4.0.3",
@@ -26067,16 +26068,18 @@
} }
}, },
"@kosmos/schemas": { "@kosmos/schemas": {
"version": "3.1.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/@kosmos/schemas/-/schemas-3.2.0.tgz",
"integrity": "sha512-GyWXmWcATcakmEdPOpafdckBgWFqcpSh+tB8nm11VGgtgA94pfoFhKZ3t88WELItKfuP/GFHgMiZmdMB3bSkdQ==",
"dev": true "dev": true
}, },
"@kredits/contracts": { "@kredits/contracts": {
"version": "7.3.0", "version": "7.5.0",
"resolved": "https://registry.npmjs.org/@kredits/contracts/-/contracts-7.3.0.tgz", "resolved": "https://registry.npmjs.org/@kredits/contracts/-/contracts-7.5.0.tgz",
"integrity": "sha512-7Bo2LFEqGIK39G54AStas1RWouBOp+r7Vcv2vP1uLbY01Usea8SHvd2OjLP6nFgsQa0n/rto9oremHv5IAylrQ==", "integrity": "sha512-4Jw6lXn9DfZiTnMtWHwGLS5zhxEJZ2ANfAgcr7tZxqRl7Y0ijpoWvPCA+LkSGqRgKspyiw9+7vSVTtyvAyQbPQ==",
"dev": true, "dev": true,
"requires": { "requires": {
"@kosmos/schemas": "^3.1.0", "@kosmos/schemas": "^3.2.0",
"ethers": "^5.4.7", "ethers": "^5.4.7",
"ipfs-http-client": "^56.0.3", "ipfs-http-client": "^56.0.3",
"multihashes": "^4.0.3", "multihashes": "^4.0.3",
+1 -1
View File
@@ -30,7 +30,7 @@
"@ember/optional-features": "^1.3.0", "@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0", "@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0", "@glimmer/tracking": "^1.0.0",
"@kredits/contracts": "^7.3.0", "@kredits/contracts": "^7.5.0",
"babel-preset-es2015": "^6.22.0", "babel-preset-es2015": "^6.22.0",
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"ember-auto-import": "^1.12.1", "ember-auto-import": "^1.12.1",
@@ -18,5 +18,12 @@ module('Integration | Helper | fmt-fiat-currency', function(hooks) {
await render(hbs`{{fmt-fiat-currency this.amount 'USD'}}`); await render(hbs`{{fmt-fiat-currency this.amount 'USD'}}`);
assert.ok(this.element.textContent.trim().match(/USD/), assert.ok(this.element.textContent.trim().match(/USD/),
'using defined currency when given'); 'using defined currency when given');
await render(hbs`{{fmt-fiat-currency 0.00123 'BTC'}}`);
assert.ok(this.element.textContent.trim().match(/0.00123/),
'allows more decimals');
assert.ok(this.element.textContent.trim().match(/BTC/),
'using defined currency when given');
}); });
}); });
@@ -39,12 +39,4 @@ module('Unit | Component | add-reimbursement', function(hooks) {
assert.equal(component.totalEUR, '71'); assert.equal(component.totalEUR, '71');
assert.equal(component.totalUSD, '59'); assert.equal(component.totalUSD, '59');
}); });
test('#updateTotalAmountFromFiat', async function(assert) {
let component = createComponent('component:add-reimbursement');
component.expenses = expenses;
await component.exchangeRates.fetchRates();
component.updateTotalAmountFromFiat();
assert.equal(component.total, '0.01323322', 'converts EUR and USD totals to BTC and rounds to 8 decimals');
});
}); });
+2 -2
View File
@@ -7,7 +7,7 @@ module('Unit | Service | exchange-rates', function(hooks) {
test('#fetchRates', async function(assert) { test('#fetchRates', async function(assert) {
let service = this.owner.lookup('service:exchange-rates'); let service = this.owner.lookup('service:exchange-rates');
await service.fetchRates(); await service.fetchRates();
assert.equal(service.btceur, 9167.57, 'fetches BTCEUR from Bitstamp'); assert.equal(service.EUR, 9167.57, 'fetches BTCEUR from Bitstamp');
assert.equal(service.btcusd, 10749.70, 'fetches BTCUSD from Bitstamp'); assert.equal(service.USD, 10749.70, 'fetches BTCUSD from Bitstamp');
}); });
}); });
+16
View File
@@ -0,0 +1,16 @@
import { floatToBtc, btcToSats, satsToBtc } from 'kredits-web/utils/btc-conversions';
import { module, test } from 'qunit';
module('Unit | Utility | btc-conversions', function() {
test('floatToBtc', function(assert) {
assert.equal(floatToBtc(0.001429007), 0.00142901);
});
test('btcToSats', function(assert) {
assert.equal(btcToSats(0.001429007), 142901);
});
test('satsToBtc', function(assert) {
assert.equal(satsToBtc(142901), 0.00142901);
});
});