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