territory billing notifications
This commit is contained in:
parent
562b243111
commit
717f8d1ef6
@ -4,6 +4,7 @@ import { getItem, filterClause, whereClause, muteClause } from './item'
|
||||
import { getInvoice } from './wallet'
|
||||
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
|
||||
import { replyToSubscription } from '../webPush'
|
||||
import { getSub } from './sub'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
@ -249,6 +250,17 @@ export default {
|
||||
)
|
||||
}
|
||||
|
||||
queries.push(
|
||||
`(SELECT "Sub".name::text, "Sub"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
|
||||
'SubStatus' AS type
|
||||
FROM "Sub"
|
||||
WHERE "Sub"."userId" = $1
|
||||
AND "status" <> 'ACTIVE'
|
||||
AND "statusUpdatedAt" <= $2
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
|
||||
// we do all this crazy subquery stuff to make 'reward' islands
|
||||
const notifications = await models.$queryRawUnsafe(
|
||||
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type,
|
||||
@ -339,6 +351,9 @@ export default {
|
||||
JobChanged: {
|
||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||
},
|
||||
SubStatus: {
|
||||
sub: async (n, args, { models, me }) => getSub(n, { name: n.id }, { models, me })
|
||||
},
|
||||
Revenue: {
|
||||
subName: async (n, args, { models }) => {
|
||||
const subAct = await models.subAct.findUnique({
|
||||
|
@ -3,17 +3,15 @@ import serialize, { serializeInvoicable } from './serial'
|
||||
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY } from '../../lib/constants'
|
||||
import { datePivot } from '../../lib/time'
|
||||
import { ssValidate, territorySchema } from '../../lib/validate'
|
||||
import { nextBilling, nextNextBilling } from '../../lib/territory'
|
||||
|
||||
export function paySubQueries (sub, models) {
|
||||
let billingAt = datePivot(sub.billedLastAt, { months: 1 })
|
||||
let billAt = datePivot(sub.billedLastAt, { months: 2 })
|
||||
if (sub.billingType === 'ONCE') {
|
||||
return []
|
||||
} else if (sub.billingType === 'YEARLY') {
|
||||
billingAt = datePivot(sub.billedLastAt, { years: 1 })
|
||||
billAt = datePivot(sub.billedLastAt, { years: 2 })
|
||||
}
|
||||
|
||||
const billingAt = nextBilling(sub)
|
||||
const billAt = nextNextBilling(sub)
|
||||
const cost = BigInt(sub.billingCost) * BigInt(1000)
|
||||
|
||||
return [
|
||||
@ -53,35 +51,37 @@ export function paySubQueries (sub, models) {
|
||||
AND completedon IS NULL`,
|
||||
// schedule 'em
|
||||
models.$queryRaw`
|
||||
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling',
|
||||
INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
|
||||
${JSON.stringify({
|
||||
subName: sub.name
|
||||
})}::JSONB, ${billAt})`
|
||||
})}::JSONB, ${billAt}, ${datePivot(billAt, { days: 1 })})`
|
||||
]
|
||||
}
|
||||
|
||||
export async function getSub (parent, { name }, { models, me }) {
|
||||
if (!name) return null
|
||||
|
||||
return await models.sub.findUnique({
|
||||
where: {
|
||||
name
|
||||
},
|
||||
...(me
|
||||
? {
|
||||
include: {
|
||||
MuteSub: {
|
||||
where: {
|
||||
userId: Number(me?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
sub: async (parent, { name }, { models, me }) => {
|
||||
if (!name) return null
|
||||
|
||||
return await models.sub.findUnique({
|
||||
where: {
|
||||
name
|
||||
},
|
||||
...(me
|
||||
? {
|
||||
include: {
|
||||
MuteSub: {
|
||||
where: {
|
||||
userId: Number(me?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
},
|
||||
sub: getSub,
|
||||
subs: async (parent, args, { models, me }) => {
|
||||
if (me) {
|
||||
return await models.$queryRaw`
|
||||
@ -249,10 +249,10 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
||||
// schedule 'em
|
||||
...(billAt
|
||||
? [models.$queryRaw`
|
||||
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling',
|
||||
INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
|
||||
${JSON.stringify({
|
||||
subName: data.name
|
||||
})}::JSONB, ${billAt})`]
|
||||
})}::JSONB, ${billAt}, ${datePivot(billAt, { days: 1 })})`]
|
||||
: [])
|
||||
], { models, lnd, hash, hmac, me, enforceFee: billingCost })
|
||||
|
||||
|
@ -462,6 +462,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const subStatus = await models.sub.findFirst({
|
||||
where: {
|
||||
userId: me.id,
|
||||
statusUpdatedAt: {
|
||||
gt: lastChecked
|
||||
},
|
||||
status: {
|
||||
not: 'ACTIVE'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (subStatus) {
|
||||
foundNotes()
|
||||
return true
|
||||
}
|
||||
|
||||
// update checkedNotesAt to prevent rechecking same time period
|
||||
models.user.update({
|
||||
where: { id: me.id },
|
||||
|
@ -96,9 +96,15 @@ export default gql`
|
||||
sortTime: Date!
|
||||
}
|
||||
|
||||
type SubStatus {
|
||||
id: ID!
|
||||
sub: Sub!
|
||||
sortTime: Date!
|
||||
}
|
||||
|
||||
union Notification = Reply | Votification | Mention
|
||||
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
||||
| Streak | FollowActivity | ForwardedVotification | Revenue
|
||||
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||
|
||||
type Notifications {
|
||||
lastChecked: Date
|
||||
|
@ -26,6 +26,8 @@ import Text from './text'
|
||||
import NostrIcon from '../svgs/nostr.svg'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import BountyIcon from '../svgs/bounty-bag.svg'
|
||||
import { LongCountdown } from './countdown'
|
||||
import { nextBillingWithGrace } from '../lib/territory'
|
||||
|
||||
function Notification ({ n, fresh }) {
|
||||
const type = n.__typename
|
||||
@ -44,6 +46,7 @@ function Notification ({ n, fresh }) {
|
||||
(type === 'Mention' && <Mention n={n} />) ||
|
||||
(type === 'JobChanged' && <JobChanged n={n} />) ||
|
||||
(type === 'Reply' && <Reply n={n} />) ||
|
||||
(type === 'SubStatus' && <SubStatus n={n} />) ||
|
||||
(type === 'FollowActivity' && <FollowActivity n={n} />)
|
||||
}
|
||||
</NotificationLayout>
|
||||
@ -86,6 +89,7 @@ const defaultOnClick = n => {
|
||||
return { href }
|
||||
}
|
||||
if (type === 'Revenue') return { href: `/~${n.subName}` }
|
||||
if (type === 'SubStatus') return { href: `/~${n.sub.name}` }
|
||||
if (type === 'Invitification') return { href: '/invites' }
|
||||
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
|
||||
if (type === 'Referral') return { href: '/referrals/month' }
|
||||
@ -190,6 +194,20 @@ function RevenueNotification ({ n }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SubStatus ({ n }) {
|
||||
const dueDate = nextBillingWithGrace(n.sub)
|
||||
return (
|
||||
<div className={`fw-bold text-${n.sub.status === 'ACTIVE' ? 'success' : 'danger'} ms-2`}>
|
||||
{n.sub.status === 'ACTIVE'
|
||||
? 'your territory is active again'
|
||||
: (n.sub.status === 'GRACE'
|
||||
? <>your territory payment for ~{n.sub.name} is due or your territory will be archived in <LongCountdown date={dueDate} /></>
|
||||
: <>your territory ~{n.sub.name} has been archived</>)}
|
||||
<small className='text-muted d-block pb-1 fw-normal'>click to visit territory and pay</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Invitification ({ n }) {
|
||||
return (
|
||||
<>
|
||||
|
@ -1,25 +1,14 @@
|
||||
import { Alert } from 'react-bootstrap'
|
||||
import { useMe } from './me'
|
||||
import FeeButton, { FeeButtonProvider } from './fee-button'
|
||||
import { TERRITORY_BILLING_OPTIONS, TERRITORY_GRACE_DAYS } from '../lib/constants'
|
||||
import { TERRITORY_BILLING_OPTIONS } from '../lib/constants'
|
||||
import { Form } from './form'
|
||||
import { datePivot, timeSince } from '../lib/time'
|
||||
import { timeSince } from '../lib/time'
|
||||
import { LongCountdown } from './countdown'
|
||||
import { useCallback } from 'react'
|
||||
import { useApolloClient, useMutation } from '@apollo/client'
|
||||
import { SUB_PAY } from '../fragments/subs'
|
||||
|
||||
const billingDueDate = (sub, grace) => {
|
||||
if (!sub || sub.billingType === 'ONCE') return null
|
||||
|
||||
const pivot = sub.billingType === 'MONTHLY'
|
||||
? { months: 1 }
|
||||
: { years: 1 }
|
||||
|
||||
pivot.days = grace ? TERRITORY_GRACE_DAYS : 0
|
||||
|
||||
return datePivot(new Date(sub.billedLastAt), pivot)
|
||||
}
|
||||
import { nextBilling, nextBillingWithGrace } from '../lib/territory'
|
||||
|
||||
export default function TerritoryPaymentDue ({ sub }) {
|
||||
const me = useMe()
|
||||
@ -39,8 +28,7 @@ export default function TerritoryPaymentDue ({ sub }) {
|
||||
|
||||
if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null
|
||||
|
||||
const dueDate = billingDueDate(sub, true)
|
||||
|
||||
const dueDate = nextBillingWithGrace(sub)
|
||||
if (!dueDate) return null
|
||||
|
||||
return (
|
||||
@ -90,12 +78,13 @@ export function TerritoryBillingLine ({ sub }) {
|
||||
const me = useMe()
|
||||
if (!sub || sub.userId !== Number(me?.id)) return null
|
||||
|
||||
const dueDate = billingDueDate(sub, false)
|
||||
const dueDate = nextBilling(sub)
|
||||
const pastDue = dueDate && dueDate < new Date()
|
||||
|
||||
return (
|
||||
<div className='text-muted'>
|
||||
<span>billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} on </span>
|
||||
<span className='fw-bold'>{dueDate ? timeSince(dueDate) : 'never again'}</span>
|
||||
<span>billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} </span>
|
||||
<span className='fw-bold'>{pastDue ? 'past due' : dueDate ? timeSince(dueDate) : 'never again'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { gql } from '@apollo/client'
|
||||
import { ITEM_FULL_FIELDS } from './items'
|
||||
import { INVITE_FIELDS } from './invites'
|
||||
import { SUB_FIELDS } from './subs'
|
||||
|
||||
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
|
||||
|
||||
export const NOTIFICATIONS = gql`
|
||||
${ITEM_FULL_FIELDS}
|
||||
${INVITE_FIELDS}
|
||||
${SUB_FIELDS}
|
||||
|
||||
query Notifications($cursor: String, $inc: String) {
|
||||
notifications(cursor: $cursor, inc: $inc) {
|
||||
@ -98,6 +100,13 @@ export const NOTIFICATIONS = gql`
|
||||
...ItemFields
|
||||
}
|
||||
}
|
||||
... on SubStatus {
|
||||
id
|
||||
sortTime
|
||||
sub {
|
||||
...SubFields
|
||||
}
|
||||
}
|
||||
... on InvoicePaid {
|
||||
id
|
||||
sortTime
|
||||
|
28
lib/territory.js
Normal file
28
lib/territory.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { TERRITORY_GRACE_DAYS } from './constants'
|
||||
import { datePivot } from './time'
|
||||
|
||||
export function nextBilling (sub) {
|
||||
if (!sub || sub.billingType === 'ONCE') return null
|
||||
|
||||
const pivot = sub.billingType === 'MONTHLY'
|
||||
? { months: 1 }
|
||||
: { years: 1 }
|
||||
|
||||
return datePivot(new Date(sub.billedLastAt), pivot)
|
||||
}
|
||||
|
||||
export function nextNextBilling (sub) {
|
||||
if (!sub || sub.billingType === 'ONCE') return null
|
||||
|
||||
const pivot = sub.billingType === 'MONTHLY'
|
||||
? { months: 2 }
|
||||
: { years: 2 }
|
||||
|
||||
return datePivot(new Date(sub.billedLastAt), pivot)
|
||||
}
|
||||
|
||||
export function nextBillingWithGrace (sub) {
|
||||
const dueDate = nextBilling(sub)
|
||||
if (!sub) return null
|
||||
return datePivot(dueDate, { days: TERRITORY_GRACE_DAYS })
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Sub" ADD COLUMN "statusUpdatedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Sub_statusUpdatedAt_idx" ON "Sub"("statusUpdatedAt");
|
||||
|
||||
CREATE OR REPLACE FUNCTION reset_territory_billing_job()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
DELETE FROM pgboss.job where name = 'territoryBilling';
|
||||
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
|
||||
SELECT 'territoryBilling', json_build_object('subName', name),
|
||||
"billedLastAt" + CASE WHEN "billingType" = 'MONTHLY' THEN interval '1 month' ELSE interval '1 year' END,
|
||||
"billedLastAt" + CASE WHEN "billingType" = 'MONTHLY' THEN interval '1 month 1 day' ELSE interval '1 year 1 day' END
|
||||
FROM "Sub"
|
||||
WHERE "billingType" <> 'ONCE';
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT reset_territory_billing_job();
|
||||
DROP FUNCTION reset_territory_billing_job();
|
@ -412,6 +412,7 @@ model Sub {
|
||||
rewardsPct Int @default(50)
|
||||
desc String?
|
||||
status Status @default(ACTIVE)
|
||||
statusUpdatedAt DateTime?
|
||||
billingType BillingType
|
||||
billingCost Int
|
||||
billingAutoRenew Boolean @default(false)
|
||||
@ -429,6 +430,7 @@ model Sub {
|
||||
@@index([parentName])
|
||||
@@index([createdAt])
|
||||
@@index([userId])
|
||||
@@index([statusUpdatedAt])
|
||||
@@index([path], type: Gist)
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import serialize from '../api/resolvers/serial'
|
||||
import { paySubQueries } from '../api/resolvers/sub'
|
||||
import { TERRITORY_GRACE_DAYS } from '../lib/constants'
|
||||
import { nextBillingWithGrace } from '../lib/territory'
|
||||
import { datePivot } from '../lib/time'
|
||||
|
||||
export async function territoryBilling ({ data: { subName }, boss, models }) {
|
||||
@ -11,14 +11,18 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
|
||||
})
|
||||
|
||||
async function territoryStatusUpdate () {
|
||||
await models.sub.update({
|
||||
where: {
|
||||
name: subName
|
||||
},
|
||||
data: {
|
||||
status: sub.billedLastAt >= datePivot(new Date(), { days: -TERRITORY_GRACE_DAYS }) ? 'GRACE' : 'STOPPED'
|
||||
}
|
||||
})
|
||||
if (sub.status !== 'STOPPED') {
|
||||
await models.sub.update({
|
||||
where: {
|
||||
name: subName
|
||||
},
|
||||
data: {
|
||||
status: nextBillingWithGrace(sub) >= new Date() ? 'GRACE' : 'STOPPED',
|
||||
statusUpdatedAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// retry billing in one day
|
||||
await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) })
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user