From 717f8d1ef61b4cdff077257f6ddaf75967de5017 Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 2 Jan 2024 20:05:49 -0600 Subject: [PATCH] territory billing notifications --- api/resolvers/notifications.js | 15 +++++ api/resolvers/sub.js | 58 +++++++++---------- api/resolvers/user.js | 17 ++++++ api/typeDefs/notifications.js | 8 ++- components/notifications.js | 18 ++++++ components/territory-payment-due.js | 27 +++------ fragments/notifications.js | 9 +++ lib/territory.js | 28 +++++++++ .../migration.sql | 27 +++++++++ prisma/schema.prisma | 2 + worker/territory.js | 22 ++++--- 11 files changed, 173 insertions(+), 58 deletions(-) create mode 100644 lib/territory.js create mode 100644 prisma/migrations/20240103184950_territory_status/migration.sql diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 4498e0cf..c394b6f3 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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({ diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index d0691fb5..f9bd65e4 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -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 }) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 3deb0c47..f51d3abc 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 }, diff --git a/api/typeDefs/notifications.js b/api/typeDefs/notifications.js index 5fc9a506..832a2932 100644 --- a/api/typeDefs/notifications.js +++ b/api/typeDefs/notifications.js @@ -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 diff --git a/components/notifications.js b/components/notifications.js index 02b3c7d1..f248bf03 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -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' && ) || (type === 'JobChanged' && ) || (type === 'Reply' && ) || + (type === 'SubStatus' && ) || (type === 'FollowActivity' && ) } @@ -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 ( +
+ {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 + : <>your territory ~{n.sub.name} has been archived)} + click to visit territory and pay +
+ ) +} + function Invitification ({ n }) { return ( <> diff --git a/components/territory-payment-due.js b/components/territory-payment-due.js index 56d71b8a..6d119a9b 100644 --- a/components/territory-payment-due.js +++ b/components/territory-payment-due.js @@ -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 (
- billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} on - {dueDate ? timeSince(dueDate) : 'never again'} + billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} + {pastDue ? 'past due' : dueDate ? timeSince(dueDate) : 'never again'}
) } diff --git a/fragments/notifications.js b/fragments/notifications.js index 551760ba..f1b93931 100644 --- a/fragments/notifications.js +++ b/fragments/notifications.js @@ -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 diff --git a/lib/territory.js b/lib/territory.js new file mode 100644 index 00000000..3339b053 --- /dev/null +++ b/lib/territory.js @@ -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 }) +} diff --git a/prisma/migrations/20240103184950_territory_status/migration.sql b/prisma/migrations/20240103184950_territory_status/migration.sql new file mode 100644 index 00000000..6b7373c8 --- /dev/null +++ b/prisma/migrations/20240103184950_territory_status/migration.sql @@ -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(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9a5fb942..171bf6c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) } diff --git a/worker/territory.js b/worker/territory.js index e593b2b3..70896507 100644 --- a/worker/territory.js +++ b/worker/territory.js @@ -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 }) }) }