territory billing notifications

This commit is contained in:
keyan 2024-01-02 20:05:49 -06:00
parent 562b243111
commit 717f8d1ef6
11 changed files with 173 additions and 58 deletions

View File

@ -4,6 +4,7 @@ import { getItem, filterClause, whereClause, muteClause } from './item'
import { getInvoice } from './wallet' import { getInvoice } from './wallet'
import { pushSubscriptionSchema, ssValidate } from '../../lib/validate' import { pushSubscriptionSchema, ssValidate } from '../../lib/validate'
import { replyToSubscription } from '../webPush' import { replyToSubscription } from '../webPush'
import { getSub } from './sub'
export default { export default {
Query: { 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 // we do all this crazy subquery stuff to make 'reward' islands
const notifications = await models.$queryRawUnsafe( const notifications = await models.$queryRawUnsafe(
`SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type, `SELECT MAX(id) AS id, MAX("sortTime") AS "sortTime", sum("earnedSats") AS "earnedSats", type,
@ -339,6 +351,9 @@ export default {
JobChanged: { JobChanged: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) 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: { Revenue: {
subName: async (n, args, { models }) => { subName: async (n, args, { models }) => {
const subAct = await models.subAct.findUnique({ const subAct = await models.subAct.findUnique({

View File

@ -3,17 +3,15 @@ import serialize, { serializeInvoicable } from './serial'
import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY } from '../../lib/constants' import { TERRITORY_COST_MONTHLY, TERRITORY_COST_ONCE, TERRITORY_COST_YEARLY } from '../../lib/constants'
import { datePivot } from '../../lib/time' import { datePivot } from '../../lib/time'
import { ssValidate, territorySchema } from '../../lib/validate' import { ssValidate, territorySchema } from '../../lib/validate'
import { nextBilling, nextNextBilling } from '../../lib/territory'
export function paySubQueries (sub, models) { export function paySubQueries (sub, models) {
let billingAt = datePivot(sub.billedLastAt, { months: 1 })
let billAt = datePivot(sub.billedLastAt, { months: 2 })
if (sub.billingType === 'ONCE') { if (sub.billingType === 'ONCE') {
return [] 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) const cost = BigInt(sub.billingCost) * BigInt(1000)
return [ return [
@ -53,35 +51,37 @@ export function paySubQueries (sub, models) {
AND completedon IS NULL`, AND completedon IS NULL`,
// schedule 'em // schedule 'em
models.$queryRaw` models.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling', INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
${JSON.stringify({ ${JSON.stringify({
subName: sub.name 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 { export default {
Query: { Query: {
sub: async (parent, { name }, { models, me }) => { sub: getSub,
if (!name) return null
return await models.sub.findUnique({
where: {
name
},
...(me
? {
include: {
MuteSub: {
where: {
userId: Number(me?.id)
}
}
}
}
: {})
})
},
subs: async (parent, args, { models, me }) => { subs: async (parent, args, { models, me }) => {
if (me) { if (me) {
return await models.$queryRaw` return await models.$queryRaw`
@ -249,10 +249,10 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
// schedule 'em // schedule 'em
...(billAt ...(billAt
? [models.$queryRaw` ? [models.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter) VALUES ('territoryBilling', INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
${JSON.stringify({ ${JSON.stringify({
subName: data.name subName: data.name
})}::JSONB, ${billAt})`] })}::JSONB, ${billAt}, ${datePivot(billAt, { days: 1 })})`]
: []) : [])
], { models, lnd, hash, hmac, me, enforceFee: billingCost }) ], { models, lnd, hash, hmac, me, enforceFee: billingCost })

View File

@ -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 // update checkedNotesAt to prevent rechecking same time period
models.user.update({ models.user.update({
where: { id: me.id }, where: { id: me.id },

View File

@ -96,9 +96,15 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type SubStatus {
id: ID!
sub: Sub!
sortTime: Date!
}
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -26,6 +26,8 @@ import Text from './text'
import NostrIcon from '../svgs/nostr.svg' import NostrIcon from '../svgs/nostr.svg'
import { numWithUnits } from '../lib/format' import { numWithUnits } from '../lib/format'
import BountyIcon from '../svgs/bounty-bag.svg' import BountyIcon from '../svgs/bounty-bag.svg'
import { LongCountdown } from './countdown'
import { nextBillingWithGrace } from '../lib/territory'
function Notification ({ n, fresh }) { function Notification ({ n, fresh }) {
const type = n.__typename const type = n.__typename
@ -44,6 +46,7 @@ function Notification ({ n, fresh }) {
(type === 'Mention' && <Mention n={n} />) || (type === 'Mention' && <Mention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) || (type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />) || (type === 'Reply' && <Reply n={n} />) ||
(type === 'SubStatus' && <SubStatus n={n} />) ||
(type === 'FollowActivity' && <FollowActivity n={n} />) (type === 'FollowActivity' && <FollowActivity n={n} />)
} }
</NotificationLayout> </NotificationLayout>
@ -86,6 +89,7 @@ const defaultOnClick = n => {
return { href } return { href }
} }
if (type === 'Revenue') return { href: `/~${n.subName}` } if (type === 'Revenue') return { href: `/~${n.subName}` }
if (type === 'SubStatus') return { href: `/~${n.sub.name}` }
if (type === 'Invitification') return { href: '/invites' } if (type === 'Invitification') return { href: '/invites' }
if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` } if (type === 'InvoicePaid') return { href: `/invoices/${n.invoice.id}` }
if (type === 'Referral') return { href: '/referrals/month' } 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 }) { function Invitification ({ n }) {
return ( return (
<> <>

View File

@ -1,25 +1,14 @@
import { Alert } from 'react-bootstrap' import { Alert } from 'react-bootstrap'
import { useMe } from './me' import { useMe } from './me'
import FeeButton, { FeeButtonProvider } from './fee-button' 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 { Form } from './form'
import { datePivot, timeSince } from '../lib/time' import { timeSince } from '../lib/time'
import { LongCountdown } from './countdown' import { LongCountdown } from './countdown'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useApolloClient, useMutation } from '@apollo/client' import { useApolloClient, useMutation } from '@apollo/client'
import { SUB_PAY } from '../fragments/subs' import { SUB_PAY } from '../fragments/subs'
import { nextBilling, nextBillingWithGrace } from '../lib/territory'
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)
}
export default function TerritoryPaymentDue ({ sub }) { export default function TerritoryPaymentDue ({ sub }) {
const me = useMe() const me = useMe()
@ -39,8 +28,7 @@ export default function TerritoryPaymentDue ({ sub }) {
if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null 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 if (!dueDate) return null
return ( return (
@ -90,12 +78,13 @@ export function TerritoryBillingLine ({ sub }) {
const me = useMe() const me = useMe()
if (!sub || sub.userId !== Number(me?.id)) return null 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 ( return (
<div className='text-muted'> <div className='text-muted'>
<span>billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} on </span> <span>billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} </span>
<span className='fw-bold'>{dueDate ? timeSince(dueDate) : 'never again'}</span> <span className='fw-bold'>{pastDue ? 'past due' : dueDate ? timeSince(dueDate) : 'never again'}</span>
</div> </div>
) )
} }

View File

@ -1,12 +1,14 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { ITEM_FULL_FIELDS } from './items' import { ITEM_FULL_FIELDS } from './items'
import { INVITE_FIELDS } from './invites' import { INVITE_FIELDS } from './invites'
import { SUB_FIELDS } from './subs'
export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }` export const HAS_NOTIFICATIONS = gql`{ hasNewNotes }`
export const NOTIFICATIONS = gql` export const NOTIFICATIONS = gql`
${ITEM_FULL_FIELDS} ${ITEM_FULL_FIELDS}
${INVITE_FIELDS} ${INVITE_FIELDS}
${SUB_FIELDS}
query Notifications($cursor: String, $inc: String) { query Notifications($cursor: String, $inc: String) {
notifications(cursor: $cursor, inc: $inc) { notifications(cursor: $cursor, inc: $inc) {
@ -98,6 +100,13 @@ export const NOTIFICATIONS = gql`
...ItemFields ...ItemFields
} }
} }
... on SubStatus {
id
sortTime
sub {
...SubFields
}
}
... on InvoicePaid { ... on InvoicePaid {
id id
sortTime sortTime

28
lib/territory.js Normal file
View 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 })
}

View File

@ -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();

View File

@ -412,6 +412,7 @@ model Sub {
rewardsPct Int @default(50) rewardsPct Int @default(50)
desc String? desc String?
status Status @default(ACTIVE) status Status @default(ACTIVE)
statusUpdatedAt DateTime?
billingType BillingType billingType BillingType
billingCost Int billingCost Int
billingAutoRenew Boolean @default(false) billingAutoRenew Boolean @default(false)
@ -429,6 +430,7 @@ model Sub {
@@index([parentName]) @@index([parentName])
@@index([createdAt]) @@index([createdAt])
@@index([userId]) @@index([userId])
@@index([statusUpdatedAt])
@@index([path], type: Gist) @@index([path], type: Gist)
} }

View File

@ -1,6 +1,6 @@
import serialize from '../api/resolvers/serial' import serialize from '../api/resolvers/serial'
import { paySubQueries } from '../api/resolvers/sub' import { paySubQueries } from '../api/resolvers/sub'
import { TERRITORY_GRACE_DAYS } from '../lib/constants' import { nextBillingWithGrace } from '../lib/territory'
import { datePivot } from '../lib/time' import { datePivot } from '../lib/time'
export async function territoryBilling ({ data: { subName }, boss, models }) { export async function territoryBilling ({ data: { subName }, boss, models }) {
@ -11,14 +11,18 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
}) })
async function territoryStatusUpdate () { async function territoryStatusUpdate () {
await models.sub.update({ if (sub.status !== 'STOPPED') {
where: { await models.sub.update({
name: subName where: {
}, name: subName
data: { },
status: sub.billedLastAt >= datePivot(new Date(), { days: -TERRITORY_GRACE_DAYS }) ? 'GRACE' : 'STOPPED' data: {
} status: nextBillingWithGrace(sub) >= new Date() ? 'GRACE' : 'STOPPED',
}) statusUpdatedAt: new Date()
}
})
}
// retry billing in one day // retry billing in one day
await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) }) await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) })
} }