Browse Source

Using the new validation framework for purchases.

master
Zed A. Shaw 2 weeks ago
parent
commit
20f75d3780
  1. 111
      api/payments/btcpay.js
  2. 72
      api/payments/paypal.js
  3. 218
      client/bando/demos/FairPay.svelte
  4. 15
      client/components/BTCPay.svelte
  5. 32
      client/components/Paypal.svelte
  6. 34
      client/pages/Purchase.svelte

111
api/payments/btcpay.js

@ -1,71 +1,80 @@
import { Payment, UserPayment } from '../../lib/models.js';
import { log } from '../../lib/logging.js';
import logging from '../../lib/logging.js';
import { API } from "../../lib/api.js";
import fetch from 'node-fetch';
import { btcpay_private } from '../../lib/config.js';
import { fake_payments, product } from '../../client/config.js';
import assert from "assert";
const rules = {
amount: "required|numeric|min:0|max:100",
}
const log = logging.create("api/payments/btcpay.js");
export const get = async (req, res) => {
const api = new API(req, res);
try {
assert(!fake_payments, "You have fake_payments set so use /api/user/payment APIs.");
assert(!btcpay_private.disabled, "Payments are disabled in lib/config.js.");
const amount = parseInt(req.query.amount || product.price, 10);
assert(amount >= 0 && amount <= 100, `invalid amount ${amount}`);
let internal_id = Payment.gen_internal_id();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
body: JSON.stringify({
"price": amount,
"currency": product.currency,
"orderId": internal_id,
"itemDesc": product.description,
}),
}
const response = await fetch(`${btcpay_private.url}/invoices`, options);
const text = await response.text();
if(response.status != 200) {
log.info(`BTCPAY error: ${response.status}, text: '${text}'`);
api.error(403, 'BTCPay server failure');
assert(!btcpay_private.disabled, "BTC Payments are disabled in secrets/config.json.");
const form = api.validate(rules);
if(form._valid) {
let internal_id = Payment.gen_internal_id();
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': btcpay_private.auth
},
body: JSON.stringify({
"price": form.amount,
"currency": product.currency,
"orderId": internal_id,
"itemDesc": product.description,
}),
}
const response = await fetch(`${btcpay_private.url}/invoices`, options);
const text = await response.text();
if(response.status != 200) {
log.info(`BTCPAY error: ${response.status}, text: '${text}'`);
return api.error(403, 'BTCPay server failure');
} else {
const data = JSON.parse(text);
const sys_primary_id = data.data.id;
let payment = await Payment.insert({
system: 'btcpay',
status: 'pending',
internal_id,
sys_primary_id,
sys_secondary_id: data.data.guid,
sys_created_on: new Date(data.data.invoiceTime)
});
assert(payment, "Failed to store payment in the database. Email help@learnjsthehardway.com.");
const user_payment = await UserPayment.insert({
user_id: api.user.id,
payment_id: payment.id
});
assert(user_payment.user_id == api.user.id, "Wrong id for user_payment");
return api.reply(200, {sys_primary_id, internal_id});
}
} else {
const data = JSON.parse(text);
const sys_primary_id = data.data.id;
let payment = await Payment.insert({
system: 'btcpay',
status: 'pending',
internal_id,
sys_primary_id,
sys_secondary_id: data.data.guid,
sys_created_on: new Date(data.data.invoiceTime)
});
assert(payment, "Failed to store payment in the database. Email help@learnjsthehardway.com.");
const user_payment = await UserPayment.insert({
user_id: api.user.id,
payment_id: payment.id
});
assert(user_payment.user_id == api.user.id, "Wrong id for user_payment");
api.reply(200, {sys_primary_id, internal_id});
log.error(form._errors, "btcpay validation failure");
return api.validation_error(res, form, rules);
}
} catch(error) {
log.error(error);
api.error(403, 'BTCPay server failure');
return api.error(403, 'BTCPay server failure');
}
}

72
api/payments/paypal.js

@ -1,53 +1,65 @@
import { Payment, UserPayment } from '../../lib/models.js';
import { API } from "../../lib/api.js";
import { log } from '../../lib/logging.js';
import logging from '../../lib/logging.js';
import dayjs from 'dayjs';
import { fake_payments } from "../../client/config.js";
import { payments_allow_zero, paypal_private } from "../../lib/config.js";
import { paypal_private } from "../../lib/config.js";
import assert from 'assert';
const log = logging.create("api/payments/paypal.js");
const rules = {
sys_primary_id: "required",
sys_secondary_id: "required",
sys_created_on: "required|date"
}
export const post = async (req, res) => {
const api = new API(req, res);
try{
assert(!fake_payments, "You have fake_payments set so use /api/user/payment APIs.");
assert(!payments_allow_zero, "You have payments_allow_zero=true so don't use /api/payments/paypal. Use /api/user/payment.");
assert(!paypal_private.disabled, "Payments are disabled in lib/config.js.");
assert(!paypal_private.disabled, "Paypal Payments are disabled in secrets/config.json");
const msg = req.body;
const msg = api.validate(rules);
log.debug("Paypal recieved message", msg);
let internal_id = Payment.gen_internal_id();
if(msg._valid) {
log.debug("Paypal recieved message", msg);
let internal_id = Payment.gen_internal_id();
// TODO: see if we can use date-fns instead of dayjs here
let payment = await Payment.insert({
system: 'paypal',
status: 'complete',
internal_id,
sys_primary_id: msg.sys_primary_id,
sys_secondary_id: msg.sys_secondary_id,
sys_created_on: dayjs(msg.create_time)
});
// TODO: see if we can use date-fns instead of dayjs here
let payment = await Payment.insert({
system: 'paypal',
status: 'complete',
internal_id,
sys_primary_id: msg.sys_primary_id,
sys_secondary_id: msg.sys_secondary_id,
sys_created_on: dayjs(msg.sys_created_on)
});
assert(payment && payment.internal_id, "Failed to save payment.");
assert(payment && payment.internal_id, "Failed to save payment.");
const user_payment = await UserPayment.insert({
user_id: api.user.id,
payment_id: payment.id
});
const user_payment = await UserPayment.insert({
user_id: api.user.id,
payment_id: payment.id
});
assert(user_payment.user_id == api.user.id, "Wrong id for user_payment");
assert(user_payment.user_id == api.user.id, "Wrong id for user_payment");
let result = {
sys_primary_id: payment.sys_primary_id,
status: payment.status,
internal_id: payment.internal_id
}
let result = {
sys_primary_id: payment.sys_primary_id,
status: payment.status,
internal_id: payment.internal_id
}
log.debug(result);
api.reply(200, result);
log.debug(result);
return api.reply(200, result);
} else {
log.error(msg._errors, "paypal validation failure");
return api.validation_error(res, msg, rules);
}
} catch(error) {
log.error(error);
api.error(403, 'PayPal configuration failed');
return api.error(403, 'PayPal configuration failed');
}
}

218
client/bando/demos/FairPay.svelte

@ -1,16 +1,14 @@
<script>
import { push, link } from 'svelte-spa-router';
import { link } from 'svelte-spa-router';
import { fade } from 'svelte/transition';
import Validator from 'Validator';
import { onMount } from 'svelte';
import api from "$/client/api.js";
import FormField, { is_valid } from "$/client/components/FormField.svelte";
import { user } from "$/client/stores.js";
import LoggedIn from "$/client/components/LoggedIn.svelte";
import FormField from "$/client/components/FormField.svelte";
import Icon from "$/client/components/Icon.svelte";
import PlaceHolder from "$/client/components/PlaceHolder.svelte";
import IconImage from "$/client/components/IconImage.svelte";
import Paypal from "$/client/components/Paypal.svelte";
import BTCPay from "$/client/components/BTCPay.svelte";
import Layout from "$/client/Layout.svelte";
import { fake_payments } from "$/client/config.js";
const quips = {
"0": "Awww, really? Alright then.",
@ -28,16 +26,14 @@
const form = {
amount: 10,
errors: {},
_errors: {},
_valid: true
}
const amount_by = 10;
const rules = {
amount: "required|numeric|min:0|max:100",
}
// BUG: we have to duplicate this logic here because of how paypal works
$: form._valid = form.amount >= 0 && form.amount <= 100;
$: is_valid(form, rules);
const amount_by = 10;
let paid_in_full = false;
let payment_failed = false;
@ -48,7 +44,7 @@
const payment_finished = (event) => {
const { system, status } = event.detail;
console.assert(status === "complete", "Payment status should be complete with finished event", event.detail);
console.assert(status === "complete", "Payment status should be complete with finished event", system, event.detail);
paid_in_full = true;
}
@ -56,9 +52,7 @@
console.log("CANCELED", event);
}
const payment_error = (event) => {
const { system, status } = event.detail;
console.log("Payment failed notice, tell Zed:", system, status);
const payment_error = () => {
payment_failed = true;
}
@ -66,17 +60,16 @@
console.log("LOADING", event);
}
const pay_nothing = async (event) => {
if(!form.valid) return;
const pay_nothing = async () => {
// posting to /api/user/payments is how you do a free purchase
let [status, data] = await api.post("/api/user/payments", { amount: form.amount }, api.logout_user);
if(status === 200) {
paid_in_full = true;
} else {
console.error("status", status, "data", data);
payment_failed = true;
form.errors.main = data.message || data.error || "Payment Error.";
form._errors.main = data.message || data.error || "Payment Error.";
}
}
</script>
@ -106,7 +99,7 @@
background-color: var(--color-bg-tertiary);
height: 4rem;
counter-reset: amount var(--amount);
content:'$' counter(amount);
content: '$' counter(amount);
transition: 0.5s;
}
@ -116,6 +109,7 @@
card {
width: 400px;
border-radius: var(--border-radius) var(--border-radius) 0px 0px;
}
card#failed top {
@ -136,98 +130,106 @@
form card bottom {
padding: 0.5rem;
}
callout {
border-radius: 0px 0px var(--border-radius) var(--border-radius);
}
</style>
{#if paid_in_full}
<card in:fade|local id="paid">
<top>
<PlaceHolder />
</top>
<middle>
<h1>Welcome!</h1>
<p>Thank you for your purchase. You can now enjoy the entire
course and attend the live lessons.
</p>
</middle>
<bottom>
<button-group>
<button><a href="/" use:link>Start Learning</a></button>
</button-group>
</bottom>
</card>
{:else}
{#if payment_failed}
<card in:fade|local id="failed">
<top><h1>Payment Error!</h1></top>
<Layout centered={ true } authenticated={ true }>
{#if paid_in_full}
<card in:fade|local id="paid">
<top>
<IconImage name="dollar-sign" />
</top>
<middle>
<p style="font-size: 1.5em">There was an error processing your payment. Please try again later.</p>
<p>You can email <a href="mailto:help@xor.academy">help@xor.academy</a> to get help with this purchase.</p>
<h1>Welcome!</h1>
<p>Thank you for your purchase. You can now enjoy the entire
site.
</p>
</middle>
<bottom>
<button-group>
<button><a href="/" use:link><Icon name="arrow-left-circle" size="36" /> Cancel</a></button>
<button on:click={ () => payment_failed = false }><Icon name="credit-card" size="36" light={ true } /> Try Again</button>
</button-group>
</bottom>
<bottom>
<button-group>
<button><a href="/" use:link>Start Browsing</a></button>
</button-group>
</bottom>
</card>
{:else}
<form method="POST" id="purchase">
<card>
<top>
{#if payment_failed }
<h1>Payment Error!</h1>
{:else}
<h1>What's a Fair Price?</h1>
{/if}
</top>
<middle>
<FormField form={ form } field="amount" label="{ form.valid ? quips[form.amount] : 'Bad Value!' }">
<label class="slider"
for="amount" style="--amount: { form.amount }"
on:click={ () => change_amount() }>
</label>
<input type="numeric" id="amount" bind:value={ form.amount } />
</FormField>
{#if form.valid}
<p>Click on the slider to change <b>how much you think this course is worth</b>, then
<b>select your payment method</b>.</p>
{:else}
<p><b>You've caused an error in this form. Please email help@xor.academy and tell them how you did this.</b></p>
{/if}
</middle>
<bottom>
<payments class:disabled={ !form.valid }>
{#if form.amount > 0}
<Paypal credit_card={ true } amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form.valid }/>
<BTCPay amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form.valid } />
{:else}
<button on:click|preventDefault={ pay_nothing }>Pay Nothing</button>
{/if}
</payments>
</bottom>
</card>
</form>
{/if}
{/if}
{#if payment_failed}
<card in:fade|local id="failed">
<top><h1>Payment Error!</h1></top>
<hr/>
<middle>
<p style="font-size: 1.5em;">There was an error processing your payment. Please try again later.</p>
<p>You can email <a href="mailto:help@xor.academy">help@xor.academy</a> to get help with this purchase.</p>
</middle>
<callout class="warning">You will need to be logged in for the payments to actually finish.</callout>
<bottom>
<button-group>
<button><a href="/" use:link><Icon name="arrow-left-circle" size="36" /> Cancel</a></button>
<button on:click={ () => payment_failed = false }><Icon name="credit-card" size="36" light={ true } /> Try Again</button>
</button-group>
</bottom>
</card>
{:else}
<form method="POST" id="purchase">
<card>
<top>
{#if payment_failed }
<h1>Payment Error!</h1>
{:else}
<h1>What's a Fair Price?</h1>
{/if}
</top>
<middle>
<FormField form={ form } field="amount" label="{ quips[form.amount] || `Bad Value! ${form.amount}` }">
<label class="slider"
for="amount" style="--amount: { form.amount };"
on:click={ () => change_amount() }>
</label>
<input type="numeric" id="amount" bind:value={ form.amount } />
</FormField>
{#if form._valid}
<p>Click on the slider to change <b>how much you think this course is worth</b>, then
<b>select your payment method</b>.</p>
{:else}
<p><b>You've caused an error in this form. Please email help@xor.academy and tell them how you did this.</b></p>
{/if}
</middle>
<bottom>
<payments class:disabled={ !form._valid }>
{#if form.amount > 0}
<Paypal credit_card={ true } amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form._valid }/>
<BTCPay amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form._valid } />
{:else}
<button on:click|preventDefault={ pay_nothing }>Pay Nothing</button>
{/if}
</payments>
</bottom>
</card>
{#if fake_payments}
<callout class="warning">
Payments are fake right now. Use a fake CC from Paypal,
and use testnet coins for BTC/LTC.
</callout>
{/if}
</form>
{/if}
{/if}
</Layout>

15
client/components/BTCPay.svelte

@ -36,13 +36,11 @@
});
if(resp.status !== 200) {
dispatch("error", resp.text());
const text = await resp.text();
dispatch("error", text);
} else {
const invoice = await resp.json();
// TODO: confirm returned invoice ids match the requested ones
console.log('BTCPay invoice data', invoice);
let payment = {
system: 'btcpay',
internal_id,
@ -61,7 +59,7 @@
// do a fake thing just to work on the UI
console.log("fake_payments set, sending finished event");
dispatch('finished', { system: 'btcpay', invoice_id: 'FAKE', sys_primary_id: 11234, status: 'complete'});
} else if(sys_primary_id !== undefined) {
} else if(sys_primary_id) {
// already have an invoice going so just open it again
loading_btc = true;
window.btcpay.showInvoice(sys_primary_id);
@ -69,12 +67,11 @@
loading_btc = true;
// looks like this is fresh, get a new invoice to pay
const resp = await fetch(`/api/payments/btcpay?amount=${ amount }`);
const data = await resp.json();
if(resp.status !== 200) {
dispatch("error", resp.text());
dispatch("error", data);
} else {
const data = await resp.json();
// TODO: handle response errors from here
window.btcpay.onModalWillEnter(btcpay_enter);
window.btcpay.onModalWillLeave(btcpay_leave);
@ -104,6 +101,8 @@
.bitcoin-logo {
max-height: 1rem;
color: #fff;
width: unset;
margin-left: 0.5rem;
}
</style>

32
client/components/Paypal.svelte

@ -5,11 +5,11 @@
<script>
import { createEventDispatcher } from 'svelte';
import { paypal_public } from "$/client/config.js";
import { inject_remote } from './Source.svelte';
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { product, fake_payments } from '../config.js';
import { product, fake_payments, paypal_public } from '$/client/config.js';
import api from "$/client/api.js";
export let credit_card = false;
export let amount = product.amount;
@ -32,24 +32,23 @@
status: 'complete',
};
const resp = await fetch('/api/payments/paypal', {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify(payment)
});
const data = await resp.json();
console.log("Paypal request returned.");
const [status, data] = await api.post('/api/payments/paypal', payment);
// TODO: confirm no errors on reply and the returned data matches
payment.internal_id = data.internal_id;
if(status == 200) {
console.log("Paypal request returned.");
dispatch('finished', payment);
// TODO: confirm no errors on reply and the returned data matches
payment.internal_id = data.internal_id;
dispatch('finished', payment);
} else {
console.log("ERROR RESPONSE", status, data);
dispatch("error", {message: "Validation error.", payment});
}
} else {
alert("Impossible! Paypal status is not complete but dispatch finished?!");
dispatch("error", {message: `dispatch_finished called but status is ${paypal.status}`});
}
}
const dispatch_canceled = (what) => dispatch('canceled', {paypal: what});
const dispatch_error = (what) => dispatch('error', {paypal: what});
const dispatch_loading = (what) => dispatch('loading', {paypal: what});
@ -139,9 +138,10 @@
}
.paypal-logo {
max-height: 1.2rem;
max-height: 1.3rem;
margin: 0.5rem;
vertical-align: bottom;
width: unset;
}
</style>
@ -153,7 +153,7 @@
{#if !paypal_loaded}
<button on:click|preventDefault={ install_paypal } data-testid="paypal-activate" class="paypal-activate" alt="Pay with Paypal">
Pay with <img class="paypal-logo" src="/images/PayPal.svg" alt="Paypal" /> or &nbsp; <Icon name="credit-card" size="36px" />
Pay with <img class="paypal-logo" src="/images/PayPal.svg" alt="Paypal" /> or <Icon name="credit-card" size="36px" />
</button>
{/if}

34
client/pages/Purchase.svelte

@ -2,7 +2,7 @@
import { link } from 'svelte-spa-router';
import { fade } from 'svelte/transition';
import api from "$/client/api.js";
import FormField, { is_valid } from "$/client/components/FormField.svelte";
import FormField from "$/client/components/FormField.svelte";
import Icon from "$/client/components/Icon.svelte";
import IconImage from "$/client/components/IconImage.svelte";
import Paypal from "$/client/components/Paypal.svelte";
@ -26,16 +26,14 @@
const form = {
amount: 10,
errors: {},
_errors: {},
_valid: true
}
const amount_by = 10;
const rules = {
amount: "required|numeric|min:0|max:100",
}
// BUG: we have to duplicate this logic here because of how paypal works
$: form._valid = form.amount >= 0 && form.amount <= 100;
$: is_valid(form, rules);
const amount_by = 10;
let paid_in_full = false;
let payment_failed = false;
@ -63,8 +61,6 @@
}
const pay_nothing = async () => {
if(!form.valid) return;
// posting to /api/user/payments is how you do a free purchase
let [status, data] = await api.post("/api/user/payments", { amount: form.amount }, api.logout_user);
@ -73,7 +69,7 @@
} else {
console.error("status", status, "data", data);
payment_failed = true;
form.errors.main = data.message || data.error || "Payment Error.";
form._errors.main = data.message || data.error || "Payment Error.";
}
}
</script>
@ -103,7 +99,7 @@
background-color: var(--color-bg-tertiary);
height: 4rem;
counter-reset: amount var(--amount);
content:'$' counter(amount);
content: '$' counter(amount);
transition: 0.5s;
}
@ -166,7 +162,7 @@
<top><h1>Payment Error!</h1></top>
<middle>
<p style="font-size: 1.5em">There was an error processing your payment. Please try again later.</p>
<p style="font-size: 1.5em;">There was an error processing your payment. Please try again later.</p>
<p>You can email <a href="mailto:help@xor.academy">help@xor.academy</a> to get help with this purchase.</p>
</middle>
@ -189,15 +185,15 @@
</top>
<middle>
<FormField form={ form } field="amount" label="{ form.valid ? quips[form.amount] : 'Bad Value!' }">
<FormField form={ form } field="amount" label="{ quips[form.amount] || `Bad Value! ${form.amount}` }">
<label class="slider"
for="amount" style="--amount: { form.amount }"
for="amount" style="--amount: { form.amount };"
on:click={ () => change_amount() }>
</label>
<input type="numeric" id="amount" bind:value={ form.amount } />
</FormField>
{#if form.valid}
{#if form._valid}
<p>Click on the slider to change <b>how much you think this course is worth</b>, then
<b>select your payment method</b>.</p>
{:else}
@ -206,21 +202,21 @@
</middle>
<bottom>
<payments class:disabled={ !form.valid }>
<payments class:disabled={ !form._valid }>
{#if form.amount > 0}
<Paypal credit_card={ true } amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form.valid }/>
disabled = { !form._valid }/>
<BTCPay amount={ form.amount }
on:error={ payment_error}
on:finished={ payment_finished }
on:canceled={ payment_canceled }
on:loading={ payment_loading }
disabled = { !form.valid } />
disabled = { !form._valid } />
{:else}
<button on:click|preventDefault={ pay_nothing }>Pay Nothing</button>
{/if}

Loading…
Cancel
Save