diff --git a/api/orders.js b/api/orders.js index 1263bd0..cbac1a0 100644 --- a/api/orders.js +++ b/api/orders.js @@ -237,3 +237,59 @@ router.post('/:uuid/ship/easypost', ensureAdmin, parseJSON, async (req, res) => res.json(updatedOrder) }) + +router.put('/manual', ensureAdmin, parseJSON, async (req, res) => { + const {items} = req.body + + if(!items?.length) + throw new Error("No items in order") + + const cart = await db.cart.create(null) + + for(const item of items) + await db.cart.addItemToCart(cart.uuid, item.uuid, item.count) + + const order = await db.order.create(cart.uuid, req.session.uuid); + res.json(order) +}) + + +router.post('/manual/:uuid/address', ensureAdmin, parseJSON, validate.address, validate.handleApiError, async (req, res) => { + const origOrder = await db.order.findByUUID(req.params.uuid); + if(!origOrder) throw new Error("Unable to find order"); + + const currentTransaction = origOrder + .transactions.find(transaction => ( + transaction.payment_state === 'started' + )) + + const {name, street1, street2, city, state, zip, country} = req.body; + + // Create address, update order + const address = await db.address.create(name, street1, street2, city, state, zip, country, null) + const order = await db.order.addAddress(currentTransaction, address) + + res.json(order) +}) + +router.post('/manual/:uuid/payment', ensureAdmin, parseJSON, validate.validEmail('recipientEmail'), validate.handleApiError, async (req, res) => { + const {recipientEmail, reason} = req.body; + + const origOrder = await db.order.findByUUID(req.params.uuid) + + const currentTransaction = origOrder + .transactions.find(transaction => ( + transaction.payment_state === 'started' + )) + + const {item_total_price, shipping_price, tax_price, coupon_effective_discount} = currentTransaction + const {free_shipping} = currentTransaction.coupon || {} + const total_price = + (item_total_price && shipping_price) + ? item_total_price + (free_shipping ? 0 : shipping_price) + tax_price - (coupon_effective_discount || 0) + : null + + const order = await db.order.addAdminPayment(currentTransaction, total_price, recipientEmail, reason, req.user.uuid) + + res.json(order) +}) \ No newline at end of file diff --git a/components/form/form.js b/components/form/form.js index 60b1bd1..1e019bd 100644 --- a/components/form/form.js +++ b/components/form/form.js @@ -164,7 +164,12 @@ export const FormController = function FormController({children, className, url, if(child.props.hidden) return null; return React.cloneElement(child, { - onChange: ev=>dispatch({name, value: ev.target.value}), + onChange: ev=> { + if(child.props.onChange) + child.props.onChange(ev.target.value) + + dispatch({name, value: ev.target.value}) + }, onBlur: ev=>dispatch({name}), value: state.fields[name].value, isValid: state.fields[name].touched ? state.fields[name].isValid : true, diff --git a/db/mappings/order.js b/db/mappings/order.js index 0d8a865..076b90e 100644 --- a/db/mappings/order.js +++ b/db/mappings/order.js @@ -67,7 +67,8 @@ module.exports = [{ properties: [ 'type', 'time', - 'value_cents' + 'value_cents', + 'recipient_email' ], associations: [ {name: 'stripe', mapId: 'paymentStripeMap', columnPrefix: 'stripe_'} diff --git a/db/models/order.js b/db/models/order.js index 690ff24..c2c807d 100644 --- a/db/models/order.js +++ b/db/models/order.js @@ -133,7 +133,7 @@ order.addAddress = async function (transaction, address){ // Get shipping price (as cents) const lowestRate = epShipment.lowestRate(['USPS']) - const price = parseFloat(lowestRate && lowestRate.retail_rate) * 100 + const price = Math.floor(parseFloat(lowestRate && lowestRate.retail_rate) * 100) if(!price) throw new Error("Unable to estimate price"); @@ -278,6 +278,30 @@ order.addPayment = async function(transaction, paymentIntent){ return order } +order.addAdminPayment = async function(transaction, payment_value, recipient_email, reason, granter_uuid){ + const order = await dbUtil.executeFunction({ + name: 'add_admin_payment_to_transaction', + params: [ + transaction.uuid, + payment_value, + recipient_email, + reason, + granter_uuid + ], + returnType: 'order', + single: true + }) + + await dbUtil.executeFunction({ + name: 'deduct_stock_for_purchase', + params: [transaction.uuid], + returnType: 'item', + single: false + }) + + return order +} + order.setTracking = (uuid, trackingCode, shipDate, price_cents) => dbUtil.executeFunction({ name: 'set_delivery_tracking', diff --git a/db/sql/1-tables.sql b/db/sql/1-tables.sql index bc0a7f1..9bf1992 100644 --- a/db/sql/1-tables.sql +++ b/db/sql/1-tables.sql @@ -1,6 +1,6 @@ create type sos."delivery_type_enum" as enum ('hand_shipped', 'easypost', 'hand_delivered'); create type sos."transaction_state_enum" as enum ('started', 'completed', 'cancelled', 'expired'); -create type sos."payment_type_enum" as enum ('ks_reward', 'stripe', 'paypal', 'account_credit'); +create type sos."payment_type_enum" as enum ('ks_reward', 'stripe', 'paypal', 'account_credit', 'admin_granted'); create type sos."stockchange_type_enum" as enum ('purchase', 'shipment', 'admin'); create type sos."stock_change_dir_enum" as enum ('added', 'subtracted'); create type sos."email_link_type_enum" as enum ('email_confirm', 'email_change', 'password_reset'); @@ -227,6 +227,16 @@ create table sos."payment_stripe" ( stripe_receipt_number text NULL ); +create table sos."payment_admin_grant" ( + payment_uuid uuid primary key default uuid_generate_v4(), + payment_type sos.payment_type_enum check (payment_type = 'admin_granted'), + foreign key (payment_uuid, payment_type) references sos."payment" (payment_uuid, payment_type), + + payment_admin_granted_by uuid not null references sos."user" (user_uuid), + payment_admin_recipient_email citext not null, + payment_admin_reason text not null +); + create table sos."shipment" ( shipment_uuid uuid primary key default uuid_generate_v4(), shipment_date timestamptz not null default now(), diff --git a/db/sql/2-views.sql b/db/sql/2-views.sql index b4cd96f..3aade9a 100644 --- a/db/sql/2-views.sql +++ b/db/sql/2-views.sql @@ -91,10 +91,13 @@ create or replace view sos.v_payment as select payment.*, payment_stripe.stripe_payment_intent_id, - payment_stripe.stripe_receipt_email + payment_admin_grant.payment_admin_granted_by, + payment_admin_grant.payment_admin_reason, + coalesce(payment_stripe.stripe_receipt_email, payment_admin_grant.payment_admin_recipient_email) as payment_recipient_email from sos."payment" - left join sos."payment_ks_reward" on payment_ks_reward.payment_uuid = payment.payment_uuid and payment_ks_reward.payment_type = payment.payment_type - left join sos."payment_stripe" on payment_stripe.payment_uuid = payment.payment_uuid and payment_stripe.payment_type = payment.payment_type; + left join sos."payment_ks_reward" on payment_ks_reward.payment_uuid = payment.payment_uuid and payment_ks_reward.payment_type = payment.payment_type + left join sos."payment_stripe" on payment_stripe.payment_uuid = payment.payment_uuid and payment_stripe.payment_type = payment.payment_type + left join sos."payment_admin_grant" on payment_admin_grant.payment_uuid = payment.payment_uuid and payment_admin_grant.payment_type = payment.payment_type; create or replace view sos.v_transaction_paid as select diff --git a/db/sql/3-functions.sql b/db/sql/3-functions.sql index 9fc2fae..4ea50b3 100644 --- a/db/sql/3-functions.sql +++ b/db/sql/3-functions.sql @@ -886,6 +886,70 @@ begin return query select * from sos.v_order where order_uuid = _order_uuid; end; $function$; +create or replace function sos.add_admin_payment_to_transaction(_transaction_uuid uuid, _payment_value_cents integer, _recipient_email citext, _reason text, _granter uuid) + returns setof sos.v_order + language plpgsql +as $function$ +declare + _payment_uuid uuid; + _is_paid integer; + _order_uuid uuid; +begin + -- Get the transaction's order + select transaction_order_uuid into _order_uuid + from sos."transaction" + where transaction_uuid = _transaction_uuid; + + if _order_uuid is null then + raise 'Transaction has no order'; + end if; + + insert into sos."payment" ( + payment_type, + payment_value_cents, + payment_transaction_uuid + ) values ( + 'admin_granted', + _payment_value_cents, + _transaction_uuid + ) returning payment_uuid into _payment_uuid; + + insert into sos."payment_admin_grant" ( + payment_uuid, + payment_type, + payment_admin_granted_by, + payment_admin_recipient_email, + payment_admin_reason + ) values ( + _payment_uuid, + 'admin_granted', + _granter, + _recipient_email, + _reason + ); + + select + count(*) into _is_paid + from sos.v_order + where transaction_uuid = _transaction_uuid + and transaction_computed_price <= transaction_amount_paid_cents; + + if _is_paid > 0 then + update sos."transaction" set ( + transaction_payment_state, + transaction_completion_time + ) = ( + 'completed', + now() + ) + where + transaction_uuid = _transaction_uuid; + end if; + + return query select * from sos.v_order where order_uuid = _order_uuid; + +end; $function$; + create or replace function sos.attach_cart(_session_uuid uuid, _cart_uuid uuid) returns setof sos.v_session language plpgsql diff --git a/pages/admin/orders/index.js b/pages/admin/orders/index.js index afb35bc..cde3d5c 100644 --- a/pages/admin/orders/index.js +++ b/pages/admin/orders/index.js @@ -15,7 +15,9 @@ export default function Orders({orders}){ return ( <> - +

Unsent:

transaction.cart.items.map(item => item.count) - ).reduce((a,b)=>(a+b)) + ).flat().reduce((a,b)=>(a+b)) } function getItemPrice(order){ @@ -79,9 +81,11 @@ function getActualShipping(order) { } function getAmountPaid(order){ - return formatMoney(order.transactions.map(({payments}) => payments.map(payment => payment.value_cents)) + return formatMoney(order.transactions.map(({payments}) => payments + .filter(payment => payment.type !== 'admin_granted') + .map(payment => payment.value_cents)) .flat() - .reduce((a,b)=>(a+b))) + .reduce((a,b)=>(a+b), 0)) } function parsePaymentTime({payments}){ diff --git a/pages/admin/orders/new/[uuid]/address.js b/pages/admin/orders/new/[uuid]/address.js new file mode 100644 index 0000000..8687423 --- /dev/null +++ b/pages/admin/orders/new/[uuid]/address.js @@ -0,0 +1,35 @@ +import React from 'react' +import ActionBar from '~/components/admin/actionBar' +import {FormController, Input, Button} from '~/components/form' +import Router from 'next/router' + +OrderAddress.getInitialProps = async ({ctx}) => { + const {data: order} = await ctx.axios.get(`/api/orders/${ctx.query.uuid}`) + return {order} +} + +export default function OrderAddress({order}) { + function afterSave(order) { + Router.push(`/admin/orders/new/${order.uuid}/payment`) + } + + const {address} = order; + + return ( + <> + + + value.length > 0}/> + value.length > 0}/> + + value.length > 0}/> + value.length > 0}/> + value.length > 0}/> + value.length > 0}/> + + + + ) +} \ No newline at end of file diff --git a/pages/admin/orders/new/[uuid]/payment.js b/pages/admin/orders/new/[uuid]/payment.js new file mode 100644 index 0000000..ffb28e0 --- /dev/null +++ b/pages/admin/orders/new/[uuid]/payment.js @@ -0,0 +1,108 @@ +import React, {useState} from 'react' +import Router from 'next/router' +import axios from 'axios' +import ActionBar from '~/components/admin/actionBar' +import {Button, FormController, Input} from '~/components/form' +import OrderSummary from '~/components/orderSummary' + +import styles from '../../../../store/checkout/style.module.css' + +ManualOrderPayment.getInitialProps = async ({ctx}) => { + const {data: order} = await ctx.axios.get(`/api/orders/${ctx.query.uuid}`) + return {order} +} + +export default function ManualOrderPayment({order}) { + const [recipientEmail, setRecipientEmail] = useState('') + const [orderNote, setOrderNote] = useState('') + + const validToSubmit = recipientEmail.length > 0 && orderNote.length > 0 + + const currentTransaction = order + .transactions.find(transaction => ( + transaction.payment_state === 'started' + )) + + const {item_total_price, shipping_price, tax_price, coupon_effective_discount} = currentTransaction + const {free_shipping} = currentTransaction.coupon || {} + const total_price = + (item_total_price && shipping_price) + ? item_total_price + (free_shipping ? 0 : shipping_price) + tax_price - (coupon_effective_discount || 0) + : null + + const formatMoney = money => { + if (money === undefined || money === null) return null; + + return '$' + (money / 100).toFixed(2) + } + + async function markPaid() { + await axios.post(`/api/orders/manual/${order.uuid}/payment`, { + recipientEmail, + reason: orderNote + }) + + Router.push('/admin/orders') + } + + return ( + <> + + + + + + value.length > 0} onChange={setRecipientEmail} /> + value.length > 0} onChange={setOrderNote} /> + + +
+

Price Total:

+
+
+
+ + {currentTransaction.cart.items.map(({uuid, count, item}) => ( + + + + + ))} + {coupon_effective_discount > 0 && ( + + + + + )} + + + + + {tax_price && ( + + + + + )} + + + + + + + + + +
{item.name} {count > 1 ? `(${count})` : ''}:{formatMoney(count * item.price_cents)}
Coupon:- {formatMoney(coupon_effective_discount)}
Shipping:{formatMoney(shipping_price) || '-'}
Sales tax:{formatMoney(tax_price)}
Total:{formatMoney(total_price) || '-'}
(Paid by admin){formatMoney(0) || '-'}
+ +
+ +
+ + + + ) +} diff --git a/pages/admin/orders/new/index.js b/pages/admin/orders/new/index.js new file mode 100644 index 0000000..16b80fa --- /dev/null +++ b/pages/admin/orders/new/index.js @@ -0,0 +1,86 @@ +import React, {useReducer} from 'react' +import Router from 'next/router' +import axios from 'axios' +import ActionBar from '~/components/admin/actionBar' +import Table from '~/components/table' +import {Button} from '~/components/form' + +ManualOrder.getInitialProps = async ({ctx}) => { + const {data: items} = await ctx.axios.get('/api/items?showUnpublished=true') + return {items} +} + +export default function ManualOrder({items}) { + const [itemState, dispatch] = useReducer(itemCountReducer, {}) + const totalNumber = Object.values(itemState).reduce((a,b) => (b+a), 0) + + const add = (item) => () => dispatch({type: 'add', item: item.uuid}) + const subtract = (item) => () => dispatch({type: 'subtract', item: item.uuid}) + const remove = (item) => () => dispatch({type: 'remove', item: item.uuid}) + const clear = () => dispatch({type: 'clear'}) + + async function createOrder(){ + const items = Object.keys(itemState) + .map(uuid => ({ uuid, count: itemState[uuid] })) + + const {data: order} = await axios.put(`/api/orders/manual`, {items}) + + Router.push(`/admin/orders/new/${order.uuid}/address`) + } + + return ( + <> + + item.name}, + {name: 'Price', extractor: item => `$${(item.price_cents / 100).toFixed(2)}`}, + {name: 'Number in stock', extractor: item => item.number_in_stock}, + {name: 'Number in order', extractor: item => ( + + + {itemState[item.uuid] || 0} + + + )}, + {name: 'Actions', extractor: item => ( + + + + )} + ]} + // Map in an id property so the table can use array.map + rows={items.map(item => ({id: item.uuid, ...item}))} + /> +

+ +

+

+ +

+ + ); +} + +function itemCountReducer(state, action) { + switch(action.type) { + case 'add': + if (state[action.item]) + return {...state, [action.item]: state[action.item] + 1} + else + return {...state, [action.item]: 1} + case 'subtract': + if (state[action.item]) + return {...state, [action.item]: state[action.item] - 1} + else + return state + case 'remove': + const newState = {...state} + delete newState[action.item] + return newState; + case 'clear': + return {}; + } +} \ No newline at end of file diff --git a/pages/store/checkout/complete.js b/pages/store/checkout/complete.js index acbf8f3..f9d4751 100644 --- a/pages/store/checkout/complete.js +++ b/pages/store/checkout/complete.js @@ -20,7 +20,7 @@ export default function CheckoutComplete({order}){ const stripePayment = latestTransaction.payments.find(p => p.stripe !== null) if(stripePayment) - email = stripePayment.stripe.receipt_email + email = stripePayment.recipient_email return ( <>