Coupon can be added to order

main
Ashelyn Dawn 4 years ago
parent 511ef3e3ee
commit 3639c1c804

@ -40,6 +40,10 @@ validators.address = [
body('zip').isString().isLength({min: 1}).withMessage('Postal code is required')
]
validators.coupon = [
body('code').isString().isLength({min: 3, max: 50}).withMessage('Coupon code is required')
]
validators.handleApiError = (req, res, next) => {
const errors = validationResult(req)

@ -33,3 +33,18 @@ router.post('/current/address', parseJSON, validate.address, validate.handleApiE
res.json(order)
})
router.post('/current/coupon', parseJSON, validate.coupon, validate.handleApiError, async (req, res) => {
const origOrder = await db.order.findForCart(req.cart.uuid);
if(!origOrder) throw new Error("Unable to find current order");
const coupon = await db.coupon.find(req.body.code)
const currentTransaction = origOrder
.transactions.find(transaction => (
transaction.payment_state === 'started'
))
const order = await db.order.addCoupon(currentTransaction, coupon)
res.json(order)
})

@ -2,13 +2,13 @@ import React from 'react'
import styles from './styles.module.css'
export default function Input({label: _label, error, hint, type, name, value, onChange, onBlur, isValid = true}){
export default function Input({inputRef, label: _label, error, hint, type, name, value, onChange, onBlur, isValid = true}){
const label = (_label === undefined) ? name.replace(name[0], name[0].toUpperCase()) : _label
return (
<div className={styles.formElementContainer}>
{label && <label htmlFor={name}>{label}:</label>}
<input className={(isValid && !error)?'':styles.invalid} type={type} name={name} value={value} onChange={onChange} onBlur={onBlur} />
<input ref={inputRef} className={(isValid && !error)?'':styles.invalid} type={type} name={name} value={value} onChange={onChange} onBlur={onBlur} />
{(hint || error) && <span className={styles.hint}>{error || (isValid ? '' : hint)}</span>}
</div>
)

@ -6,5 +6,6 @@ module.exports = {
session: require('./models/session'),
cart: require('./models/cart'),
order: require('./models/order'),
address: require('./models/address')
address: require('./models/address'),
coupon: require('./models/coupon')
}

@ -25,7 +25,8 @@ module.exports = [{
'tax_price'
],
associations: [
{name: 'cart', mapId: 'cartMap', columnPrefix: 'cart_'}
{name: 'cart', mapId: 'cartMap', columnPrefix: 'cart_'},
{name: 'coupon', mapId: 'couponMap', columnPrefix: 'coupon_'}
]
},{
mapId: 'addressMap',
@ -42,4 +43,17 @@ module.exports = [{
'phone',
'easypost_id'
]
},{
mapId: 'couponMap',
idProperty: 'uuid',
properties: [
'code',
'valid_until',
'free_shipping',
'number_allowed_uses',
'flat_discount_cents',
'percent_discount',
'per_sock_discount_cents',
'number_of_socks_free'
]
}]

@ -0,0 +1,41 @@
const pg = require('../pg')
const joinjs = require('join-js').default;
const debug = require('debug')('sos:db:coupon')
const mappings = require('../mappings')
const coupon = module.exports = {}
coupon.create = async function(code, valid_until, free_shipping, number_allowed_uses, flat_discount_cents, percent_discount, per_sock_discount_cents, number_of_socks_free){
const query = {
text: 'select * from sos.create_coupon($1, $2, $3, $4, $5, $6, $7, $8)',
values: [
code,
valid_until,
free_shipping,
number_allowed_uses,
flat_discount_cents,
percent_discount,
per_sock_discount_cents,
number_of_socks_free
]
}
debug(query)
const {rows} = await pg.query(query);
return joinjs.map(rows, mappings, 'couponMap', 'coupon_')[0]
}
coupon.find = async function(code){
const query = {
text: 'select * from sos.coupon where coupon_code = $1',
values: [
code
]
}
debug(query)
const {rows} = await pg.query(query);
return joinjs.map(rows, mappings, 'couponMap', 'coupon_')[0]
}

@ -2,7 +2,9 @@ const pg = require('../pg')
const joinjs = require('join-js').default;
const debug = require('debug')('sos:db:order')
const mappings = require('../mappings')
const dbUtil = require('../util')
const {DateTime} = require('luxon')
const easypost = new (require('@easypost/api'))(process.env.EASYPOST_API_KEY);
const order = module.exports = {}
@ -77,3 +79,56 @@ order.addAddress = async function (transaction, address){
return joinjs.map(rows, mappings, 'orderMap', 'order_')[0];
}
order.addCoupon = function(transaction, coupon) {
// Check coupon validity
const couponExpireTime = DateTime.fromJSDate(coupon.valid_until)
if(couponExpireTime.diffNow().as('seconds') < 0)
throw new Error("Coupon has expired");
const {item_total_price} = transaction
let discount = 0;
if(coupon.flat_discount_cents)
discount = coupon.flat_discount_cents;
if(coupon.percent_discount)
discount = Math.ceil(item_total_price * coupon.percent_discount / 100)
if(coupon.per_sock_discount_cents)
discount = transaction.cart.items
// For each item type, create an array of length n (number of items of that type in cart)
// where each index is initialized to the item price
.map(({item, count}) => new Array(count).fill(item.price_cents))
// Flatten to just be all prices
.flat()
// For each price, convert it to the per-item discount (max of item price)
.map(price => Math.min(price, coupon.per_sock_discount_cents))
// Sum all the discounts
.reduce((a,c) => (a+c))
if(coupon.number_of_socks_free)
discount = transaction.cart.items
// For each item type, create an array of length n (number of items of that type in cart)
// where each index is initialized to the item price
.map(({item, count}) => new Array(count).fill(item.price_cents))
// Flatten to just be all prices
.flat()
// Order so the most expensive are at the front
.sort((a,b) => (b-a))
// Keep only first n items
.slice(0, coupon.number_of_socks_free)
// Sum the item costs for total discount
.reduce((a,c) => (a+c))
return dbUtil.executeFunction({
name: 'add_coupon_to_transaction',
params: [
transaction.uuid,
coupon.uuid,
discount
],
returnType: 'order',
single: true
})
}

@ -156,7 +156,16 @@ create table sos."coupon" (
coupon_flat_discount_cents integer not null default 0 check (coupon_flat_discount_cents >= 0),
coupon_percent_discount integer not null default 0 check (coupon_percent_discount >= 0 and coupon_percent_discount <= 100),
coupon_per_sock_discount_cents integer not null default 0 check (coupon_per_sock_discount_cents >= 0),
coupon_number_of_socks_free integer not null default 0 check (coupon_number_of_socks_free >= 0)
coupon_number_of_socks_free integer not null default 0 check (coupon_number_of_socks_free >= 0),
constraint only_one_coupon_discount check (
(
case when coupon_flat_discount_cents = 0 then 0 else 1 end
+ case when coupon_percent_discount = 0 then 0 else 1 end
+ case when coupon_per_sock_discount_cents = 0 then 0 else 1 end
+ case when coupon_number_of_socks_free = 0 then 0 else 1 end
) < 2
)
);
create table sos."transaction" (

@ -82,5 +82,6 @@ create or replace view sos.v_cart_price as
create or replace view sos.v_order as
select * from sos."order"
left join sos."transaction" on transaction_order_uuid = order_uuid
left join sos."coupon" on transaction_coupon_uuid = coupon_uuid
left join sos."address" on order_address_uuid = address_uuid
left join sos.v_cart on cart_uuid = transaction_cart_uuid;

@ -573,3 +573,74 @@ begin
return query select * from sos.v_order where order_uuid = _order_uuid;
end; $function$;
create or replace function sos.create_coupon(_code varchar(50), _valid_until timestamptz, _free_shipping boolean, _number_allowed_uses integer, _flat_discount_cents integer, _percent_discount integer, _per_sock_discount_cents integer, _number_of_socks_free integer)
returns setof sos.coupon
language plpgsql
as $function$
declare
_coupon_uuid uuid;
begin
insert into sos."coupon" (
coupon_code,
coupon_valid_until,
coupon_free_shipping,
coupon_number_allowed_uses,
coupon_flat_discount_cents,
coupon_percent_discount,
coupon_per_sock_discount_cents,
coupon_number_of_socks_free
) values (
_code,
_valid_until,
_free_shipping,
_number_allowed_uses,
_flat_discount_cents,
_percent_discount,
_per_sock_discount_cents,
_number_of_socks_free
) returning coupon_uuid into _coupon_uuid;
return query select * from sos.coupon where coupon_uuid = _coupon_uuid;
end; $function$;
create or replace function sos.add_coupon_to_transaction(_transaction_uuid uuid, _coupon_uuid uuid, _item_discount integer)
returns setof sos.v_order
language plpgsql
as $function$
declare
_order_uuid uuid;
_purchased_transactions integer;
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;
-- TODO: Check for other uses of this coupon. Compare to the number_allowed_uses.
-- Check that this transaction is "started"
select count(*) into _purchased_transactions
from sos.v_order
where transaction_uuid = _transaction_uuid
and transaction_payment_state != 'started';
if _purchased_transactions > 0 then
raise 'Order has been cancelled or purchased';
end if;
-- Update transaction
update sos."transaction" set (
transaction_coupon_uuid,
transaction_coupon_effective_discount
) = (
_coupon_uuid,
_item_discount
) where transaction_uuid = _transaction_uuid;
return query select * from sos.v_order where order_uuid = _order_uuid;
end; $function$;

@ -0,0 +1,31 @@
const pg = require('./pg')
const mappings = require('./mappings')
const joinjs = require('join-js').default;
const debug = require('debug')('sos:db')
const util = module.exports = {};
const validateFunctionName = name => /^[a-z_]+$/.test(name)
const getParamString = (length) => Array.from({length}, (_,i)=>i+1).map(i => '$' + i).join(', ')
util.executeQuery = async function({query, returnType, single = false}){
debug(query)
const {rows} = await pg.query(query)
const mappedObjs = joinjs.map(rows, mappings, returnType + 'Map', returnType + '_')
if(single)
return mappedObjs[0]
return mappedObjs
}
util.executeFunction = async function({name, params, returnType, single}) {
if(!validateFunctionName(name)) throw new Error("Invalid function name: " + name);
const query = {
text: `select * from sos.${name}( ${getParamString(params.length)} )`,
values: params
}
return util.executeQuery({query, returnType, single})
}

@ -1,9 +1,11 @@
import {useState, useRef} from 'react'
import Link from 'next/link'
import Router from 'next/router'
import styles from './style.module.css'
import {Icon} from '@rmwc/icon'
import {Input, Button} from '~/components/form'
import useUser from '~/hooks/useUser'
import axios from 'axios'
// TODO: Load previous addresses
CheckoutSummary.getInitialProps = async function({ctx: {axios}}){
@ -11,8 +13,9 @@ CheckoutSummary.getInitialProps = async function({ctx: {axios}}){
return {order}
}
export default function CheckoutSummary({order}){
export default function CheckoutSummary({order: _order}){
const user = useUser();
const [order, updateOrder] = useState(_order)
const currentTransaction = order
.transactions.find(transaction => (
@ -20,7 +23,7 @@ export default function CheckoutSummary({order}){
))
const {item_total_price, shipping_price, tax_price, coupon_effective_discount} = currentTransaction
console.log(tax_price)
const {free_shipping} = currentTransaction.coupon || {}
const formatMoney = money => {
if (money === undefined || money === null) return null;
@ -30,9 +33,19 @@ export default function CheckoutSummary({order}){
const total_price =
(item_total_price && shipping_price && tax_price)
? item_total_price + shipping_price + tax_price - (coupon_effective_discount || 0)
? item_total_price + (free_shipping ? 0 : shipping_price) + tax_price - (coupon_effective_discount || 0)
: null
// For coupon input
const couponRef = useRef()
const onCouponSubmit = async ev => {
if(ev) ev.preventDefault()
const code = couponRef.current?.value
const {data: updatedOrder} = await axios.post(`/api/orders/current/coupon`, {code})
updateOrder(updatedOrder)
}
return (
<>
<h2>Checkout</h2>
@ -79,38 +92,63 @@ export default function CheckoutSummary({order}){
</div>
<div className={styles.checkoutSection}>
<h3>Coupon (optional)</h3>
<div className={styles.horizContainer} style={{maxWidth:'400px', margin: '0 auto'}}>
<Input label="" name="coupon" />
<div style={{maxWidth: '120px', marginLeft: '8px'}}>
<Button outline>Save Coupon</Button>
</div>
</div>
{
currentTransaction.coupon
? (
<>
<p style={{textAlign: 'center'}}>
Using coupon code: <strong>{currentTransaction.coupon.code}</strong><br/>
(
{coupon_effective_discount > 0 && `Discounting by ${formatMoney(coupon_effective_discount)}`}
{coupon_effective_discount > 0 && free_shipping && ' & '}
{free_shipping && `${coupon_effective_discount > 0 ? 'f' : 'F'}ree shipping`}
)
</p>
</>
)
: (
<div className={styles.horizContainer} style={{maxWidth:'400px', margin: '0 auto'}}>
<Input inputRef={couponRef} label="" name="coupon" />
<div style={{maxWidth: '120px', marginLeft: '8px'}}>
<Button onClick={onCouponSubmit} outline>Save Coupon</Button>
</div>
</div>
)
}
</div>
<div className={styles.checkoutSection}>
<h3>Total</h3>
<div className={styles.horizContainer}>
<div>
<table>
{currentTransaction.cart.items.map(({uuid, count, item}) => (
<tr key={uuid}>
<td>{item.name} {count > 1 ? `(${count})` : ''}:</td>
<td>{formatMoney(count * item.price_cents)}</td>
<tbody>
{currentTransaction.cart.items.map(({uuid, count, item}) => (
<tr key={uuid}>
<td>{item.name} {count > 1 ? `(${count})` : ''}:</td>
<td>{formatMoney(count * item.price_cents)}</td>
</tr>
))}
{coupon_effective_discount > 0 && (
<tr>
<td>Coupon:</td>
<td>- {formatMoney(coupon_effective_discount)}</td>
</tr>
)}
<tr className={free_shipping ? 'strikethrough' : undefined}>
<td>Shipping:</td>
<td>{formatMoney(shipping_price) || '-'}</td>
</tr>
))}
<tr>
<td>Shipping:</td>
<td>{formatMoney(shipping_price) || '-'}</td>
</tr>
{tax_price && (
<tr>
<td>Sales tax:</td>
<td>{formatMoney(tax_price)}</td>
{tax_price && (
<tr>
<td>Sales tax:</td>
<td>{formatMoney(tax_price)}</td>
</tr>
)}
<tr style={{fontWeight: 'bold'}}>
<td>Total:</td>
<td>{formatMoney(total_price) || '-'}</td>
</tr>
)}
<tr style={{fontWeight: 'bold'}}>
<td>Total:</td>
<td>{formatMoney(total_price) || '-'}</td>
</tr>
</tbody>
</table>
</div>
<div className={styles.paymentButtons}>

@ -95,3 +95,18 @@ button.buttonLink:focus {
column-count: 1;
}
}
table tr.strikethrough {
opacity: .6;
position: relative;
}
table tr.strikethrough::after {
content: ' ';
position: absolute;
display: block;
left: 0;
right: 0;
top: 50%;
border-top: solid 1px black;
}

Loading…
Cancel
Save