Replace FundError with InvoiceModal (#455)
* invoices are no longer deleted to prevent double-spends but marked as confirmed. therefore, during checkInvoice, we also check if the invoice is already confirmed. * instead of showing FundError (with "fund wallet" and "pay invoice" as options), we now always immediately show an invoice * since flagging, paying bounties and poll voting used FundError but only allowed spending from balance, they now also support paying per invoice Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
parent
ac45fdc234
commit
803acd1fc4
|
@ -62,6 +62,9 @@ async function checkInvoice (models, hash, hmac, fee) {
|
|||
if (expired) {
|
||||
throw new GraphQLError('invoice expired', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
if (invoice.confirmedAt) {
|
||||
throw new GraphQLError('invoice already used', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (invoice.cancelled) {
|
||||
throw new GraphQLError('invoice was canceled', { extensions: { code: 'BAD_INPUT' } })
|
||||
|
@ -708,14 +711,25 @@ export default {
|
|||
return rItem
|
||||
}
|
||||
},
|
||||
pollVote: async (parent, { id }, { me, models }) => {
|
||||
pollVote: async (parent, { id, hash, hmac }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
await serialize(models,
|
||||
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`,
|
||||
Number(id), Number(me.id)))
|
||||
let invoice
|
||||
if (hash) {
|
||||
invoice = await checkInvoice(models, hash, hmac)
|
||||
}
|
||||
|
||||
const trx = [
|
||||
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id))
|
||||
]
|
||||
if (invoice) {
|
||||
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
|
||||
}
|
||||
|
||||
await serialize(models, ...trx)
|
||||
|
||||
return id
|
||||
},
|
||||
|
@ -756,7 +770,7 @@ export default {
|
|||
]
|
||||
if (invoice) {
|
||||
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
|
||||
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
|
||||
}
|
||||
|
||||
const query = await serialize(models, ...trx)
|
||||
|
@ -810,12 +824,17 @@ export default {
|
|||
sats
|
||||
}
|
||||
},
|
||||
dontLikeThis: async (parent, { id }, { me, models }) => {
|
||||
dontLikeThis: async (parent, { id, hash, hmac }, { me, models }) => {
|
||||
// need to make sure we are logged in
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
let invoice
|
||||
if (hash) {
|
||||
invoice = await checkInvoice(models, hash, hmac, DONT_LIKE_THIS_COST)
|
||||
}
|
||||
|
||||
// disallow self down votes
|
||||
const [item] = await models.$queryRawUnsafe(`
|
||||
${SELECT}
|
||||
|
@ -825,8 +844,16 @@ export default {
|
|||
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
|
||||
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`)
|
||||
const trx = [
|
||||
models.$queryRaw`SELECT item_act(${Number(id)}::INTEGER,
|
||||
${me.id}::INTEGER, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST}::INTEGER)`
|
||||
]
|
||||
if (invoice) {
|
||||
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
|
||||
}
|
||||
|
||||
await serialize(models, ...trx)
|
||||
|
||||
return true
|
||||
}
|
||||
|
@ -1154,7 +1181,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||
]
|
||||
if (invoice) {
|
||||
trx.unshift(models.$queryRaw`UPDATE users SET msats = msats + ${invoice.msatsReceived} WHERE id = ${invoice.user.id}`)
|
||||
trx.push(models.invoice.delete({ where: { hash: invoice.hash } }))
|
||||
trx.push(models.invoice.update({ where: { hash: invoice.hash }, data: { confirmedAt: new Date() } }))
|
||||
}
|
||||
|
||||
const query = await serialize(models, ...trx)
|
||||
|
|
|
@ -33,9 +33,9 @@ export default gql`
|
|||
text: String!, url: String!, maxBid: Int!, status: String, logo: Int, hash: String, hmac: String): Item!
|
||||
upsertPoll(id: ID, sub: String, title: String!, text: String, options: [String!]!, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!
|
||||
upsertComment(id:ID, text: String!, parentId: ID, hash: String, hmac: String): Item!
|
||||
dontLikeThis(id: ID!): Boolean!
|
||||
dontLikeThis(id: ID!, hash: String, hmac: String): Boolean!
|
||||
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
|
||||
pollVote(id: ID!): ID!
|
||||
pollVote(id: ID!, hash: String, hmac: String): ID!
|
||||
}
|
||||
|
||||
type PollOption {
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { gql, useMutation } from '@apollo/client'
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import FundError from './fund-error'
|
||||
import { useShowModal } from './modal'
|
||||
import { useToast } from './toast'
|
||||
import { InvoiceModal, payOrLoginError } from './invoice'
|
||||
import { DONT_LIKE_THIS_COST } from '../lib/constants'
|
||||
|
||||
export default function DontLikeThisDropdownItem ({ id }) {
|
||||
const toaster = useToast()
|
||||
|
@ -10,8 +11,8 @@ export default function DontLikeThisDropdownItem ({ id }) {
|
|||
|
||||
const [dontLikeThis] = useMutation(
|
||||
gql`
|
||||
mutation dontLikeThis($id: ID!) {
|
||||
dontLikeThis(id: $id)
|
||||
mutation dontLikeThis($id: ID!, $hash: String, $hmac: String) {
|
||||
dontLikeThis(id: $id, hash: $hash, hmac: $hmac)
|
||||
}`, {
|
||||
update (cache) {
|
||||
cache.modify({
|
||||
|
@ -37,9 +38,17 @@ export default function DontLikeThisDropdownItem ({ id }) {
|
|||
toaster.success('item flagged')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
if (payOrLoginError(error)) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
return (
|
||||
<InvoiceModal
|
||||
amount={DONT_LIKE_THIS_COST}
|
||||
onPayment={async ({ hash, hmac }) => {
|
||||
await dontLikeThis({ variables: { id, hash, hmac } })
|
||||
toaster.success('item flagged')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
} else {
|
||||
toaster.danger('failed to flag item')
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import Link from 'next/link'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import { useInvoiceable } from './invoice'
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function FundError ({ onClose, amount, onPayment }) {
|
||||
const [error, setError] = useState(null)
|
||||
const createInvoice = useInvoiceable(onPayment, { forceInvoice: true })
|
||||
return (
|
||||
<>
|
||||
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
|
||||
<p className='fw-bolder text-center'>you need more sats</p>
|
||||
<div className='d-flex pb-3 pt-2 justify-content-center'>
|
||||
<Link href='/wallet?type=fund'>
|
||||
<Button variant='success' onClick={onClose}>fund wallet</Button>
|
||||
</Link>
|
||||
<span className='d-flex mx-3 fw-bold text-muted align-items-center'>or</span>
|
||||
<Button variant='success' onClick={() => createInvoice({ amount }).catch(err => setError(err.message || err))}>pay invoice</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const payOrLoginError = (error) => {
|
||||
const matches = ['insufficient funds', 'you must be logged in or pay']
|
||||
if (Array.isArray(error)) {
|
||||
return error.some(({ message }) => matches.some(m => message.includes(m)))
|
||||
}
|
||||
return matches.some(m => error.toString().includes(m))
|
||||
}
|
|
@ -10,10 +10,9 @@ import InvoiceStatus from './invoice-status'
|
|||
import { useMe } from './me'
|
||||
import { useShowModal } from './modal'
|
||||
import { sleep } from '../lib/time'
|
||||
import FundError, { payOrLoginError } from './fund-error'
|
||||
import Countdown from './countdown'
|
||||
|
||||
export function Invoice ({ invoice, onPayment, successVerb }) {
|
||||
export function Invoice ({ invoice, onPayment, info, successVerb }) {
|
||||
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||
|
||||
let variant = 'default'
|
||||
|
@ -55,6 +54,7 @@ export function Invoice ({ invoice, onPayment, successVerb }) {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{info && <div className='text-muted fst-italic text-center'>{info}</div>}
|
||||
<div className='w-100'>
|
||||
{nostr
|
||||
? <AccordianItem
|
||||
|
@ -74,6 +74,7 @@ export function Invoice ({ invoice, onPayment, successVerb }) {
|
|||
}
|
||||
|
||||
const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresAt, ...props }) => {
|
||||
const me = useMe()
|
||||
const { data, loading, error } = useQuery(INVOICE, {
|
||||
pollInterval: 1000,
|
||||
variables: { id }
|
||||
|
@ -99,9 +100,10 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
|
|||
if (errorCount > 1) {
|
||||
errorStatus = 'Something still went wrong.\nYou can retry or cancel the invoice to return your funds.'
|
||||
}
|
||||
const info = me ? 'Any additional received sats will fund your account' : null
|
||||
return (
|
||||
<>
|
||||
<Invoice invoice={data.invoice} {...props} />
|
||||
<Invoice invoice={data.invoice} info={info} {...props} />
|
||||
{errorCount > 0
|
||||
? (
|
||||
<>
|
||||
|
@ -128,7 +130,8 @@ const MutationInvoice = ({ id, hash, hmac, errorCount, repeat, onClose, expiresA
|
|||
|
||||
const defaultOptions = {
|
||||
forceInvoice: false,
|
||||
requireSession: false
|
||||
requireSession: false,
|
||||
replaceModal: false
|
||||
}
|
||||
export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
||||
const me = useMe()
|
||||
|
@ -193,7 +196,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
onPayment={onPayment(onClose, invoice.hmac)}
|
||||
successVerb='received'
|
||||
/>
|
||||
), { keepOpen: true }
|
||||
), { replaceModal: options.replaceModal, keepOpen: true }
|
||||
)
|
||||
}
|
||||
}, [invoice?.id])
|
||||
|
@ -214,21 +217,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
try {
|
||||
return await onSubmit(formValues, ...submitArgs)
|
||||
} catch (error) {
|
||||
if (payOrLoginError(error)) {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<FundError
|
||||
onClose={onClose}
|
||||
amount={cost}
|
||||
onPayment={async ({ satsReceived, hash, hmac }) => {
|
||||
await onSubmit({ satsReceived, hash, hmac, ...formValues }, ...submitArgs)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return { keepLocalStorage: true }
|
||||
if (!payOrLoginError(error)) {
|
||||
throw error
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
setFormValues(formValues)
|
||||
|
@ -241,3 +232,19 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
|
||||
return onSubmitWrapper
|
||||
}
|
||||
|
||||
export const InvoiceModal = ({ onPayment, amount }) => {
|
||||
const createInvoice = useInvoiceable(onPayment, { replaceModal: true })
|
||||
|
||||
useEffect(() => {
|
||||
createInvoice({ amount })
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const payOrLoginError = (error) => {
|
||||
const matches = ['insufficient funds', 'you must be logged in or pay']
|
||||
if (Array.isArray(error)) {
|
||||
return error.some(({ message }) => matches.some(m => message.includes(m)))
|
||||
}
|
||||
return matches.some(m => error.toString().includes(m))
|
||||
}
|
||||
|
|
|
@ -59,7 +59,9 @@ export default function useModal () {
|
|||
const showModal = useCallback(
|
||||
(getContent, options) => {
|
||||
if (modalContent) {
|
||||
setModalStack(stack => ([...stack, modalContent]))
|
||||
if (options?.replaceModal) {
|
||||
setModalStack(stack => ([]))
|
||||
} else setModalStack(stack => ([...stack, modalContent]))
|
||||
}
|
||||
setModalOptions(options)
|
||||
setModalContent(getContent(onClose))
|
||||
|
|
|
@ -6,8 +6,8 @@ import { useMutation, gql } from '@apollo/client'
|
|||
import { useMe } from './me'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import { useShowModal } from './modal'
|
||||
import FundError from './fund-error'
|
||||
import { useRoot } from './root'
|
||||
import { InvoiceModal, payOrLoginError } from './invoice'
|
||||
|
||||
export default function PayBounty ({ children, item }) {
|
||||
const me = useMe()
|
||||
|
@ -16,8 +16,8 @@ export default function PayBounty ({ children, item }) {
|
|||
|
||||
const [act] = useMutation(
|
||||
gql`
|
||||
mutation act($id: ID!, $sats: Int!) {
|
||||
act(id: $id, sats: $sats) {
|
||||
mutation act($id: ID!, $sats: Int!, $hash: String, $hmac: String) {
|
||||
act(id: $id, sats: $sats, hash: $hash, hmac: $hmac) {
|
||||
sats
|
||||
}
|
||||
}`, {
|
||||
|
@ -73,9 +73,16 @@ export default function PayBounty ({ children, item }) {
|
|||
})
|
||||
onComplete()
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
if (payOrLoginError(error)) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
return (
|
||||
<InvoiceModal
|
||||
amount={root.bounty}
|
||||
onPayment={async ({ hash, hmac }) => {
|
||||
await act({ variables: { id: item.id, sats: root.bounty, hash, hmac } })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
@ -8,15 +8,16 @@ import Check from '../svgs/checkbox-circle-fill.svg'
|
|||
import { signIn } from 'next-auth/react'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import { useShowModal } from './modal'
|
||||
import FundError from './fund-error'
|
||||
import { POLL_COST } from '../lib/constants'
|
||||
import { InvoiceModal } from './invoice'
|
||||
|
||||
export default function Poll ({ item }) {
|
||||
const me = useMe()
|
||||
const showModal = useShowModal()
|
||||
const [pollVote] = useMutation(
|
||||
gql`
|
||||
mutation pollVote($id: ID!) {
|
||||
pollVote(id: $id)
|
||||
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
|
||||
pollVote(id: $id, hash: $hash, hmac: $hmac)
|
||||
}`, {
|
||||
update (cache, { data: { pollVote } }) {
|
||||
cache.modify({
|
||||
|
@ -60,11 +61,16 @@ export default function Poll ({ item }) {
|
|||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.toString().includes('insufficient funds')) {
|
||||
showModal(onClose => {
|
||||
return <FundError onClose={onClose} />
|
||||
})
|
||||
}
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<InvoiceModal
|
||||
amount={item.pollCost || POLL_COST}
|
||||
onPayment={async ({ hash, hmac }) => {
|
||||
await pollVote({ variables: { id: v.id, hash, hmac } })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
: signIn}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import UpBolt from '../svgs/bolt.svg'
|
||||
import styles from './upvote.module.css'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import FundError, { payOrLoginError } from './fund-error'
|
||||
import ActionTooltip from './action-tooltip'
|
||||
import ItemAct from './item-act'
|
||||
import { useMe } from './me'
|
||||
|
@ -13,6 +12,7 @@ import Popover from 'react-bootstrap/Popover'
|
|||
import { useShowModal } from './modal'
|
||||
import { LightningConsumer, useLightning } from './lightning'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import { InvoiceModal, payOrLoginError } from './invoice'
|
||||
|
||||
const getColor = (meSats) => {
|
||||
if (!meSats || meSats <= 10) {
|
||||
|
@ -180,8 +180,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
|
|||
if (payOrLoginError(error)) {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<FundError
|
||||
onClose={onClose}
|
||||
<InvoiceModal
|
||||
amount={pendingSats}
|
||||
onPayment={async ({ hash, hmac }) => {
|
||||
await act({ variables: { ...variables, hash, hmac } })
|
||||
|
|
|
@ -24,7 +24,9 @@ function checkInvoice ({ boss, models, lnd }) {
|
|||
|
||||
const expired = new Date(inv.expires_at) <= new Date()
|
||||
|
||||
if (inv.is_confirmed) {
|
||||
if (inv.is_confirmed && !inv.is_held) {
|
||||
// never mark hodl invoices as confirmed here because
|
||||
// we manually confirm them when we settle them
|
||||
await serialize(models,
|
||||
models.$executeRaw`SELECT confirm_invoice(${inv.id}, ${Number(inv.received_mtokens)})`)
|
||||
return boss.send('nip57', { hash })
|
||||
|
|
Loading…
Reference in New Issue