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 { 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({

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 { 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,16 +51,14 @@ 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 default {
Query: {
sub: async (parent, { name }, { models, me }) => {
export async function getSub (parent, { name }, { models, me }) {
if (!name) return null
return await models.sub.findUnique({
@ -81,7 +77,11 @@ export default {
}
: {})
})
},
}
export default {
Query: {
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 })

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
models.user.update({
where: { id: me.id },

View File

@ -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

View File

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

View File

@ -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>
)
}

View File

@ -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
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)
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)
}

View File

@ -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 () {
if (sub.status !== 'STOPPED') {
await models.sub.update({
where: {
name: subName
},
data: {
status: sub.billedLastAt >= datePivot(new Date(), { days: -TERRITORY_GRACE_DAYS }) ? 'GRACE' : 'STOPPED'
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 }) })
}