Live status of transaction

This commit is contained in:
2020-12-27 23:27:35 +01:00
parent 8168674516
commit 7f8ae69e2e
12 changed files with 791 additions and 127 deletions

View File

@@ -5,6 +5,8 @@ import * as express from 'express';
import * as rpc from 'jayson';
import * as mongoose from 'mongoose';
import * as winston from 'winston';
import * as socketio from 'socket.io';
import { Server } from 'http';
import { config } from '../config';
import { hashPassword, randomPepper, randomString } from './helper/crypto';
@@ -12,6 +14,7 @@ import { InvoiceScheduler } from './helper/invoiceScheduler';
import { User } from './models/user/user.model';
import { invoiceRouter } from './routes/invoice';
import { userRouter } from './routes/user';
import { SocketManager } from './helper/socketio';
// Load .env
dconfig({ debug: true, encoding: 'UTF-8' });
@@ -23,6 +26,7 @@ export const INVOICE_SECRET = process.env.INVOICE_SECRET || "";
export let rpcClient: rpc.HttpClient | undefined = undefined;
export let invoiceScheduler: InvoiceScheduler | undefined = undefined;
export let socketManager: SocketManager | undefined = undefined;
export let logger: winston.Logger;
@@ -105,15 +109,22 @@ async function run() {
invoiceScheduler = new InvoiceScheduler();
const app = express();
const http = new Server(app);
// Socket.io
const io = new socketio(http);
socketManager = new SocketManager(io);
app.use(express.json());
app.use(cors());
app.use(bodyParser.json({ limit: '2kb' }));
app.get('/', (req, res) => res.status(200).send('OK'));
app.use('/invoice', invoiceRouter);
app.use('/user', userRouter);
app.listen(config.http.port, config.http.host, () => {
app.get('/', (req, res) => res.status(200).send('OK'));
http.listen(config.http.port, config.http.host, () => {
logger.info(`HTTP server started on port ${config.http.host}:${config.http.port}`);
});

View File

@@ -1,9 +1,12 @@
import { Request, Response } from "express";
import { invoiceScheduler, INVOICE_SECRET } from "../app";
import { CryptoUnits, FiatUnits } from "../helper/types";
import { ICart, IInvoice } from "../models/invoice/invoice.interface";
import { Invoice } from "../models/invoice/invoice.model";
import { rpcClient } from '../app';
import { Request, Response } from 'express';
import got from 'got';
import { invoiceScheduler, INVOICE_SECRET, rpcClient } from '../app';
import { randomString } from '../helper/crypto';
import { CryptoUnits, FiatUnits, findCryptoBySymbol, PaymentStatus } from '../helper/types';
import { ICart, IInvoice, IPaymentMethod } from '../models/invoice/invoice.interface';
import { Invoice } from '../models/invoice/invoice.model';
import { calculateCart } from '../models/invoice/invoice.schema';
// POST /invoice/?sercet=XYZ
export async function createInvoice(req: Request, res: Response) {
@@ -20,14 +23,14 @@ export async function createInvoice(req: Request, res: Response) {
}
}
const paymentMethods: CryptoUnits[] = req.body.methods;
const paymentMethodsRaw: string[] = req.body.methods;
const successUrl: string = req.body.successUrl;
const cancelUrl: string = req.body.cancelUrl;
const cart: ICart[] = req.body.cart;
const currency: FiatUnits = req.body.currency;
const totalPrice: number = req.body.totalPrice;
let currency: FiatUnits = req.body.currency;
let totalPrice: number = req.body.totalPrice;
if (paymentMethods === undefined) {
if (paymentMethodsRaw === undefined) {
res.status(400).send({ message: '"paymentMethods" are not provided!' });
return;
}
@@ -45,19 +48,52 @@ export async function createInvoice(req: Request, res: Response) {
if (currency === undefined) {
res.status(400).send({ message: '"currency" is not provided!' });
return;
} else {
if (Object.keys(FiatUnits).indexOf(currency.toUpperCase()) === -1) {
res.status(400).send({ message: '"currency" can only be "eur" and "usd"' });
return;
} else {
currency = FiatUnits[currency.toUpperCase()];
}
}
/*if (cart === undefined && totalPrice === undefined) {
if (cart === undefined && totalPrice === undefined) {
res.status(400).send({ message: 'Either "cart" or "totalPrice" has to be defined.' });
return;
}*/
}
rpcClient.request('getnewaddress', ['', 'bech32'], async (err, response) => {
if (err) throw err;
//console.log(response.result);
// Get price
// Convert coin symbol to full text in order to query Coin Gecko. eg.: ['btc', 'xmr'] => ['bitcoin', 'monero']
let cgFormat = [];
paymentMethodsRaw.forEach(coin => {
const crypto = findCryptoBySymbol(coin);
if (crypto !== undefined) {
cgFormat.push(crypto.toLowerCase());
}
});
const request = await got.get(`https://api.coingecko.com/api/v3/simple/price?ids=${cgFormat.join(',')}&vs_currencies=${currency.toLowerCase()}`, {
responseType: 'json'
});
// Calulate total price, if cart is provided
if (cart !== undefined && totalPrice === undefined) {
totalPrice = calculateCart(cart);
}
let paymentMethods: IPaymentMethod[] = [];
Object.keys(request.body).forEach(coin => {
paymentMethods.push({ method: CryptoUnits[coin.toUpperCase()], amount: totalPrice / Number(request.body[coin][currency.toLowerCase()]) });
});
Invoice.create({
paymentMethods,
selector: randomString(128),
paymentMethods: paymentMethods,
successUrl,
cancelUrl,
cart,
@@ -72,26 +108,37 @@ export async function createInvoice(req: Request, res: Response) {
}
invoiceScheduler.addInvoice(invoice);
res.status(200).send({ id: invoice.id });
res.status(200).send({ id: invoice.selector });
});
});
}
// GET /invoice/
// GET /invoice/:id
// GET /invoice/:selector
export async function getInvoice(req: Request, res: Response) {
const invoiceId = req.params.id;
const selector = req.params.selector;
// If an id is provided
if (invoiceId !== undefined) {
const invoice: any = await Invoice.findById(invoiceId);
if (selector !== undefined) {
const invoice: IInvoice = await Invoice.findOne({ selector: selector });
if (invoice === null) {
res.status(404).send();
return;
}
res.status(200).send(invoice);
if(invoice.status === PaymentStatus.UNCONFIRMED || invoice.status === PaymentStatus.DONE) {
rpcClient.request('gettransaction', [invoice.transcationHashes[0]], (err, message) => {
let invoiceClone: any = invoice;
console.log(message.result.confirmations);
invoiceClone['confirmation'] = message.result.confirmations;
res.status(200).send(invoiceClone);
});
} else {
res.status(200).send(invoice);
}
return;
}
@@ -117,4 +164,22 @@ export async function getInvoice(req: Request, res: Response) {
.sort({ createdAt: sort });
res.status(200).send(invoices);
}
// DELETE /invoice/:selector
export async function cancelPaymnet(req: Request, res: Response) {
const selector = req.params.selector;
// If an id is provided
if (selector !== undefined) {
const invoice = await Invoice.findOne({ selector: selector });
if (invoice === null) {
res.status(404).send();
return;
}
invoice.status = PaymentStatus.CANCELLED;
await invoice.save();
return;
}
}

View File

@@ -1,18 +1,21 @@
import { IInvoice } from "../models/invoice/invoice.interface";
import { Subscriber } from 'zeromq';
import { logger, rpcClient } from "../app";
import { logger, rpcClient, socketManager } from "../app";
import { invoiceRouter } from "../routes/invoice";
import { Invoice } from "../models/invoice/invoice.model";
import { PaymentStatus } from "./types";
import { CryptoUnits, PaymentStatus } from "./types";
import { config } from "../../config";
export class InvoiceScheduler {
private pendingInvoices: IInvoice[];
private unconfirmedTranscations: IInvoice[];
private knownConfirmations: Map<string, number>; // Invoice id / confirmation cound
private sock: Subscriber;
constructor() {
this.unconfirmedTranscations = [];
this.pendingInvoices = [];
this.knownConfirmations = new Map<string, number>();
// Get all pending transcations
Invoice.find({ status: PaymentStatus.PENDING }).then(invoices => {
@@ -44,7 +47,7 @@ export class InvoiceScheduler {
logger.info('Now listing for incoming transaction to any invoices ...');
for await (const [topic, msg] of this.sock) {
const rawtx = msg.toString('hex');
logger.debug(`New tx: ${rawtx}`);
//logger.debug(`New tx: ${rawtx}`);
rpcClient.request('decoderawtransaction', [rawtx], (err, decoded) => {
if (err) {
logger.error(`Error while decoding raw tx: ${err.message}`);
@@ -54,19 +57,29 @@ export class InvoiceScheduler {
decoded.result.vout.forEach(output => {
// Loop over each output and check if the address of one matches the one of an invoice.
this.pendingInvoices.forEach(invoice => {
// We found our transaction
if (output.scriptPubKey.addresses === undefined) return; // Sometimes (weird) transaction don't have any addresses
// We found our transaction (https://developer.bitcoin.org/reference/rpc/decoderawtransaction.html)
if (output.scriptPubKey.addresses.indexOf(invoice.receiveAddress) !== -1) {
invoice.paid += output.value;
logger.info(`Transcation for invoice ${invoice.id} received! (${decoded.result.hash})`);
// Change state in database
invoice.status = PaymentStatus.UNCONFIRMED;
invoice.transcationHash = decoded.result.txid;
invoice.save();
// Push to array & remove from pending
this.unconfirmedTranscations.push(invoice);
this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1);
const price = invoice.paymentMethods.find((item) => { return item.method === CryptoUnits.BITCOIN }).amount;
if (invoice.paid < price - config.transcations.acceptMargin) {
const left = price - output.value;
invoice.status = PaymentStatus.PARTIALLY;
invoice.save();
logger.info(`Transcation for invoice ${invoice.id} received but there are still ${left} BTC missing (${decoded.result.hash})`);
} else {
invoice.status = PaymentStatus.UNCONFIRMED;
invoice.transcationHashes.push(decoded.result.txid);
invoice.save();
// Push to array & remove from pending
this.unconfirmedTranscations.push(invoice);
this.pendingInvoices.splice(this.pendingInvoices.indexOf(invoice), 1);
}
}
})
});
@@ -81,22 +94,40 @@ export class InvoiceScheduler {
private watchConfirmations() {
setInterval(() => {
this.unconfirmedTranscations.forEach(invoice => {
rpcClient.request('gettransaction', [invoice.transcationHash], (err, message) => {
if (err) {
logger.error(`Error while fetching confirmation state of ${invoice.transcationHash}: ${err.message}`);
return;
}
if (invoice.transcationHashes.length === 0) return;
let trustworthy = true; // Will be true if all transactions are above threshold.
if (Number(message.result.confirmations) > 2) {
logger.info(`Transaction (${invoice.transcationHash}) has reached more then 2 confirmations and can now be trusted!`);
invoice.status = PaymentStatus.DONE;
invoice.save(); // This will trigger a post save hook that will notify the user.
for (let i = 0; i < invoice.transcationHashes.length; i++) {
const transcation = invoice.transcationHashes[i];
rpcClient.request('gettransaction', [transcation], (err, message) => {
if (err) {
logger.error(`Error while fetching confirmation state of ${transcation}: ${err.message}`);
trustworthy = false;
this.unconfirmedTranscations.splice(this.unconfirmedTranscations.indexOf(invoice), 1);
} else {
logger.debug(`Transcation (${invoice.transcationHash}) has not reached his threshold yet.`);
}
});
return;
}
if (this.knownConfirmations.get(invoice.id) != message.result.confirmations) {
this.knownConfirmations.set(invoice.id, message.result.confirmations);
socketManager.getSocketByInvoice(invoice).emit('confirmationUpdate', { count: Number(message.result.confirmations) });
}
if (Number(message.result.confirmations) > 0) {
logger.info(`Transaction (${transcation}) has reached more then 2 confirmations and can now be trusted!`);
this.unconfirmedTranscations.splice(this.unconfirmedTranscations.indexOf(invoice), 1);
} else {
trustworthy = false;
logger.debug(`Transcation (${transcation}) has not reached his threshold yet.`);
}
});
}
if (trustworthy) {
invoice.status = PaymentStatus.DONE;
invoice.save(); // This will trigger a post save hook that will notify the user.
}
});
}, 2_000);
}

60
src/helper/socketio.ts Normal file
View File

@@ -0,0 +1,60 @@
import { Server, Socket } from "socket.io";
import { logger } from "../app";
import { IInvoice } from "../models/invoice/invoice.interface";
import { Invoice } from "../models/invoice/invoice.model";
import { PaymentStatus } from "./types";
export class SocketManager {
io: Server;
private socketInvoice: Map<string, string>; // Socket ID / _id
private idSocket: Map<string, Socket>; // Socket ID / Socket
private invoiceSocket: Map<string, Socket>; // _id / Socket
constructor(io: Server) {
this.io = io;
this.socketInvoice = new Map<string, string>();
this.idSocket = new Map<string, Socket>();
this.invoiceSocket = new Map<string, Socket>();
this.listen();
}
listen() {
console.log("Listen");
this.io.on('connection', (socket: Socket) => {
this.idSocket.set(socket.id, socket);
// The frontend sends his selector, then pick _id and put it in `socketInvoice` map.
// Return `true` if successful and `false` if not.
socket.on('subscribe', async data => {
if (data.selector !== undefined) {
const invoice = await Invoice.findOne({ selector: data.selector });
if (invoice === null) {
socket.emit('subscribe', false);
return;
}
logger.info(`Socket ${socket.id} has subscribed to invoice ${invoice.id} (${PaymentStatus[invoice.status]})`);
this.socketInvoice.set(socket.id, invoice.id);
this.invoiceSocket.set(invoice.id, socket);
socket.emit('subscribe', true);
}
});
});
}
getSocketById(id: string) {
return this.idSocket.get(id);
}
async getInvoiceBySocket(socketId: string) {
const invoiceId = this.socketInvoice.get(socketId);
return await Invoice.findById(invoiceId);
}
getSocketByInvoice(invoice: IInvoice) {
return this.invoiceSocket.get(invoice.id);
}
}

View File

@@ -7,9 +7,16 @@ export enum CryptoUnits {
MONERO = 'XMR'
}
export function findCryptoBySymbol(symbol: string): string | null {
for (let coin in CryptoUnits) {
if (CryptoUnits[coin] === symbol.toUpperCase()) return coin;
}
return null;
}
export enum FiatUnits {
USD = 'USD',
EUR = 'EURO'
EUR = 'EUR'
}
export enum PaymentStatus {
@@ -18,13 +25,23 @@ export enum PaymentStatus {
*/
PENDING = 0,
/**
* The payment has been paid, but not completly.
*/
PARTIALLY = 1,
/**
* The payment has been made but it's not yet confirmed.
*/
UNCONFIRMED = 1,
UNCONFIRMED = 2,
/**
* The payment is completed and the crypto is now available.
*/
DONE = 2
DONE = 3,
/**
* The payment has been cancelled by the user.
*/
CANCELLED = 4
}

View File

@@ -8,19 +8,30 @@ export interface ICart {
quantity: number;
}
export interface IPaymentMethod {
method: CryptoUnits;
amount: number
}
export interface IInvoice extends Document {
selector: string;
// Available payment methods
// [btc, xmr, eth, doge]
paymentMethods: CryptoUnits[];
// [{ method: 'btc', amount: 0.0000105 }]
paymentMethods: IPaymentMethod[];
// 1Kss3e9iPB9vTgWJJZ1SZNkkFKcFJXPz9t
receiveAddress: string;
paidWith?: CryptoUnits;
// Already paid amount, in case that not the entire amount was paid with once.
// 0.000013
paid?: number;
// Is set when invoice got paid
// 3b38c3a215d4e7981e1516b2dcbf76fca58911274d5d55b3d615274d6e10f2c1
transcationHash?: string;
transcationHashes?: string[];
cart?: ICart[];
totalPrice?: number;

View File

@@ -1,6 +1,7 @@
import { NativeError, Schema, SchemaTypes } from 'mongoose';
import { Schema } from 'mongoose';
import { socketManager } from '../../app';
import { CryptoUnits, FiatUnits, PaymentStatus } from '../../helper/types';
import { IInvoice } from './invoice.interface';
import { ICart, IInvoice } from './invoice.interface';
const urlRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/
@@ -9,16 +10,23 @@ const schemaCart = new Schema({
name: { type: String, trim: true, required: true },
image: { type: String, match: urlRegex, required: true },
quantity: { type: Number, default: 1 }
})
}, { _id: false });
const schemaPaymentMethods = new Schema({
method: { type: String, enum: Object.values(CryptoUnits), required: true },
amount: { type: Number, required: false }
}, { _id: false });
const schemaInvoice = new Schema({
paymentMethods: [{ type: String, enum: Object.values(CryptoUnits), default: [CryptoUnits.BITCOIN], required: true }],
selector: { type: String, length: 128, required: true },
paymentMethods: [{ type: schemaPaymentMethods, required: true }],
receiveAddress: { type: String, required: true },
paidWith: { type: String, enum: CryptoUnits },
transcationHash: { type: String, required: false },
paid: { type: Number, default: 0 },
transcationHashes: [{ type: String, required: false }],
cart: [{ type: schemaCart, required: false }],
totalPrice: { type: Number, required: false },
currency: { type: String, enum: Object.values(FiatUnits), required: false },
currency: { type: String, enum: Object.values(FiatUnits), required: true },
dueBy: { type: Number, required: true },
status: { type: Number, enum: Object.values(PaymentStatus), default: PaymentStatus.PENDING },
email: { type: String, required: false },
@@ -31,8 +39,15 @@ const schemaInvoice = new Schema({
versionKey: false
});
schemaInvoice.pre('validate', function(next) {
let self = this as IInvoice;
self.currency = FiatUnits[self.currency];
next();
});
// Validate values
schemaInvoice.post('validate', function (res, next) {
schemaInvoice.post('validate', function (doc, next) {
let self = this as IInvoice;
// If cart is undefined and price too, error.
@@ -40,19 +55,27 @@ schemaInvoice.post('validate', function (res, next) {
next(new Error('Either cart or price has to be defined!'));
return;
}
next();
});
// If cart is provided, calculate price.
if (self.cart !== undefined && self.totalPrice === undefined) {
let totalPrice = 0;
for (let i = 0; i < self.cart.length; i++) {
const item = self.cart[i];
totalPrice += item.price * item.quantity;
}
schemaInvoice.post('save', function(doc, next) {
let self = this as IInvoice;
self.set({ totalPrice });
}
if (socketManager.getSocketByInvoice(self) === undefined) return;
socketManager.getSocketByInvoice(self).emit('status', self.status);
next();
})
export function calculateCart(cart: ICart[]): number {
let totalPrice = 0;
for (let i = 0; i < cart.length; i++) {
const item = cart[i];
totalPrice += item.price * item.quantity;
}
return totalPrice;
}
export { schemaInvoice }

View File

@@ -4,7 +4,7 @@ import { MW_User } from "../controllers/user";
const invoiceRouter = Router()
invoiceRouter.get('/:id', getInvoice);
invoiceRouter.get('/:selector', getInvoice);
invoiceRouter.get('/', MW_User, getInvoice);
invoiceRouter.post('/', MW_User, createInvoice);