Make territory billing period changeable (#840)

* allow updates to territory billing

* simplify prorating

* handle updates during grace period and rehydrating archive
This commit is contained in:
Keyan 2024-02-16 12:25:12 -06:00 committed by GitHub
parent cfd762a5b6
commit 798fab097d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 271 additions and 123 deletions

View File

@ -1,9 +1,9 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { serializeInvoicable } from './serial' import { 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, TERRITORY_PERIOD_COST } from '../../lib/constants'
import { datePivot, whenRange } from '../../lib/time' import { datePivot, whenRange } from '../../lib/time'
import { ssValidate, territorySchema } from '../../lib/validate' import { ssValidate, territorySchema } from '../../lib/validate'
import { nextBilling, nextNextBilling } from '../../lib/territory' import { nextBilling, proratedBillingCost } from '../../lib/territory'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { subViewGroup } from './growth' import { subViewGroup } from './growth'
@ -12,9 +12,20 @@ export function paySubQueries (sub, models) {
return [] return []
} }
const billingAt = nextBilling(sub) // if in active or grace, consider we are billing them from where they are paid up
const billAt = nextNextBilling(sub) // and use grandfathered cost
const cost = BigInt(sub.billingCost) * BigInt(1000) let billedLastAt = sub.billPaidUntil
let billingCost = sub.billingCost
// if the sub is archived, they are paying to reactivate it
if (sub.status === 'STOPPED') {
// get non-grandfathered cost and reset their billing to start now
billedLastAt = new Date()
billingCost = TERRITORY_PERIOD_COST(sub.billingType)
}
const billPaidUntil = nextBilling(billedLastAt, sub.billingType)
const cost = BigInt(billingCost) * BigInt(1000)
return [ return [
models.user.update({ models.user.update({
@ -33,7 +44,9 @@ export function paySubQueries (sub, models) {
name: sub.name name: sub.name
}, },
data: { data: {
billedLastAt: billingAt, billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE' status: 'ACTIVE'
} }
}), }),
@ -45,18 +58,7 @@ export function paySubQueries (sub, models) {
msats: cost, msats: cost,
type: 'BILLING' type: 'BILLING'
} }
}), })
models.$executeRaw`
DELETE FROM pgboss.job
WHERE name = 'territoryBilling'
AND data->>'subName' = ${sub.name}
AND completedon IS NULL`,
// schedule 'em
models.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
${JSON.stringify({
subName: sub.name
})}::JSONB, ${billAt}, ${datePivot(billAt, { days: 1 })})`
] ]
} }
@ -241,16 +243,16 @@ export default {
} }
async function createSub (parent, data, { me, models, lnd, hash, hmac }) { async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
const { billingType, nsfw } = data const { billingType } = data
let billingCost = TERRITORY_COST_MONTHLY let billingCost = TERRITORY_COST_MONTHLY
let billAt = datePivot(new Date(), { months: 1 }) let billPaidUntil = datePivot(new Date(), { months: 1 })
if (billingType === 'ONCE') { if (billingType === 'ONCE') {
billingCost = TERRITORY_COST_ONCE billingCost = TERRITORY_COST_ONCE
billAt = null billPaidUntil = null
} else if (billingType === 'YEARLY') { } else if (billingType === 'YEARLY') {
billingCost = TERRITORY_COST_YEARLY billingCost = TERRITORY_COST_YEARLY
billAt = datePivot(new Date(), { years: 1 }) billPaidUntil = datePivot(new Date(), { years: 1 })
} }
const cost = BigInt(1000) * BigInt(billingCost) const cost = BigInt(1000) * BigInt(billingCost)
@ -272,10 +274,10 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
models.sub.create({ models.sub.create({
data: { data: {
...data, ...data,
billPaidUntil,
billingCost, billingCost,
rankingType: 'WOT', rankingType: 'WOT',
userId: me.id, userId: me.id
nsfw
} }
}), }),
// record 'em // record 'em
@ -286,15 +288,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
msats: cost, msats: cost,
type: 'BILLING' type: 'BILLING'
} }
}), })
// schedule 'em
...(billAt
? [models.$queryRaw`
INSERT INTO pgboss.job (name, data, startafter, keepuntil) VALUES ('territoryBilling',
${JSON.stringify({
subName: data.name
})}::JSONB, ${billAt}, ${datePivot(billAt, { days: 1 })})`]
: [])
], { models, lnd, hash, hmac, me, enforceFee: billingCost }) ], { models, lnd, hash, hmac, me, enforceFee: billingCost })
return results[1] return results[1]
@ -307,26 +301,88 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
} }
async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) { async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash, hmac }) {
// prevent modification of billingType const oldSub = await models.sub.findUnique({
delete data.billingType where: {
name: oldName,
userId: me.id,
// this function's logic is only valid if the sub is not stopped
// so prevent updates to stopped subs
status: {
not: 'STOPPED'
}
}
})
if (!oldSub) {
throw new GraphQLError('sub not found', { extensions: { code: 'BAD_INPUT' } })
}
try { try {
const results = await models.$transaction([ // if the cost is changing, record the new cost and update billing job
if (oldSub.billingType !== data.billingType) {
// make sure the current cost is recorded so they are grandfathered in
data.billingCost = TERRITORY_PERIOD_COST(data.billingType)
// we never want to bill them again if they are changing to ONCE
if (data.billingType === 'ONCE') {
data.billPaidUntil = null
data.billingAutoRenew = false
}
// if they are changing to YEARLY, bill them in a year
// if they are changing to MONTHLY from YEARLY, do nothing
if (oldSub.billingType === 'MONTHLY' && data.billingType === 'YEARLY') {
data.billPaidUntil = datePivot(new Date(oldSub.billedLastAt), { years: 1 })
}
// if this billing change makes their bill paid up, set them to active
if (data.billPaidUntil === null || data.billPaidUntil >= new Date()) {
data.status = 'ACTIVE'
}
// if the billing type is changing such that it's more expensive, bill 'em the difference
const proratedCost = proratedBillingCost(oldSub, data.billingType)
if (proratedCost > 0) {
const cost = BigInt(1000) * BigInt(proratedCost)
const results = await serializeInvoicable([
models.user.update({
where: {
id: me.id
},
data: {
msats: {
decrement: cost
}
}
}),
models.subAct.create({
data: {
userId: me.id,
subName: oldName,
msats: cost,
type: 'BILLING'
}
}),
models.sub.update({ models.sub.update({
data, data,
where: { where: {
name: oldName, name: oldName,
userId: me.id userId: me.id
} }
}), })
models.$queryRaw` ], { models, lnd, hash, hmac, me, enforceFee: proratedCost })
UPDATE pgboss.job return results[2]
SET data = ${JSON.stringify({ subName: data.name })}::JSONB }
WHERE name = 'territoryBilling' }
AND data->>'subName' = ${oldName}`
])
return results[0] // if we get here they are changin in a way that doesn't cost them anything
return await models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
})
} catch (error) { } catch (error) {
if (error.code === 'P2002') { if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })

View File

@ -36,6 +36,7 @@ export default gql`
billingAutoRenew: Boolean! billingAutoRenew: Boolean!
rankingType: String! rankingType: String!
billedLastAt: Date! billedLastAt: Date!
billPaidUntil: Date
baseCost: Int! baseCost: Int!
status: String! status: String!
moderated: Boolean! moderated: Boolean!

View File

@ -3,12 +3,14 @@ import { Col, InputGroup, Row, Form as BootstrapForm, Badge } from 'react-bootst
import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form' import { Checkbox, CheckboxGroup, Form, Input, MarkdownInput } from './form'
import FeeButton, { FeeButtonProvider } from './fee-button' import FeeButton, { FeeButtonProvider } from './fee-button'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS } from '../lib/constants' import { MAX_TERRITORY_DESC_LENGTH, POST_TYPES, TERRITORY_BILLING_OPTIONS, TERRITORY_PERIOD_COST } from '../lib/constants'
import { territorySchema } from '../lib/validate' import { territorySchema } from '../lib/validate'
import { useMe } from './me' import { useMe } from './me'
import Info from './info' import Info from './info'
import { abbrNum } from '../lib/format'
import { purchasedType } from '../lib/territory'
export default function TerritoryForm ({ sub }) { export default function TerritoryForm ({ sub }) {
const router = useRouter() const router = useRouter()
@ -54,9 +56,24 @@ export default function TerritoryForm ({ sub }) {
) )
const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase()) const [billing, setBilling] = useState((sub?.billingType || 'MONTHLY').toLowerCase())
const lineItems = useMemo(() => {
const lines = { territory: TERRITORY_BILLING_OPTIONS('first')[billing] }
if (!sub) return lines
// we are changing billing type so prorate the change
if (sub?.billingType?.toLowerCase() !== billing) {
const alreadyBilled = TERRITORY_PERIOD_COST(purchasedType(sub))
lines.paid = {
term: `- ${abbrNum(alreadyBilled)} sats`,
label: 'already paid',
modifier: cost => cost - alreadyBilled
}
return lines
}
}, [sub, billing])
return ( return (
<FeeButtonProvider baseLineItems={sub ? undefined : { territory: TERRITORY_BILLING_OPTIONS('first')[billing] }}> <FeeButtonProvider baseLineItems={lineItems}>
<Form <Form
initial={{ initial={{
name: sub?.name || '', name: sub?.name || '',
@ -74,7 +91,8 @@ export default function TerritoryForm ({ sub }) {
onSubmit={onSubmit} onSubmit={onSubmit}
className='mb-5' className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'} storageKeyPrefix={sub ? undefined : 'territory'}
> <Input >
<Input
label='name' label='name'
name='name' name='name'
required required
@ -147,51 +165,56 @@ export default function TerritoryForm ({ sub }) {
</Col> </Col>
</Row> </Row>
</CheckboxGroup> </CheckboxGroup>
{sub?.billingType !== 'ONCE' &&
<>
<CheckboxGroup <CheckboxGroup
label='billing' label={
<span className='d-flex align-items-center'>billing
{sub && sub.billingType !== 'ONCE' &&
<Info>
You will be credited what you paid for your current billing period when you change your billing period to a longer duration.
If you change from yearly to monthly, when your year ends, your monthly billing will begin.
</Info>}
</span>
}
name='billing' name='billing'
groupClassName='mb-0' groupClassName={billing !== 'once' ? 'mb-0' : ''}
> >
{(!sub?.billingType || sub.billingType === 'MONTHLY') &&
<Checkbox <Checkbox
type='radio' type='radio'
label='100k sats/month' label='100k sats/month'
value='MONTHLY' value='MONTHLY'
name='billingType' name='billingType'
id='monthly-checkbox' id='monthly-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('monthly')} handleChange={checked => checked && setBilling('monthly')}
groupClassName='ms-1 mb-0' groupClassName='ms-1 mb-0'
/>} />
{(!sub?.billingType || sub.billingType === 'YEARLY') &&
<Checkbox <Checkbox
type='radio' type='radio'
label='1m sats/year' label='1m sats/year'
value='YEARLY' value='YEARLY'
name='billingType' name='billingType'
id='yearly-checkbox' id='yearly-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('yearly')} handleChange={checked => checked && setBilling('yearly')}
groupClassName='ms-1 mb-0' groupClassName='ms-1 mb-0'
/>} />
{(!sub?.billingType || sub.billingType === 'ONCE') &&
<Checkbox <Checkbox
type='radio' type='radio'
label='3m sats once' label='3m sats once'
value='ONCE' value='ONCE'
name='billingType' name='billingType'
id='once-checkbox' id='once-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('once')} handleChange={checked => checked && setBilling('once')}
groupClassName='ms-1 mb-0' groupClassName='ms-1 mb-0'
/>} />
</CheckboxGroup> </CheckboxGroup>
{billing !== 'once' && {billing !== 'once' &&
<Checkbox <Checkbox
label='auto renew' label='auto-renew'
name='billingAutoRenew' name='billingAutoRenew'
groupClassName='ms-1 mt-2' groupClassName='ms-1 mt-2'
/>} />}
</>}
<AccordianItem <AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>} header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={ body={

View File

@ -8,7 +8,7 @@ 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' import { nextBillingWithGrace } from '../lib/territory'
export default function TerritoryPaymentDue ({ sub }) { export default function TerritoryPaymentDue ({ sub }) {
const me = useMe() const me = useMe()
@ -78,7 +78,7 @@ 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 = nextBilling(sub) const dueDate = sub.billPaidUntil && new Date(sub.billPaidUntil)
const pastDue = dueDate && dueDate < new Date() const pastDue = dueDate && dueDate < new Date()
return ( return (

View File

@ -12,6 +12,7 @@ export const SUB_FIELDS = gql`
billingCost billingCost
billingAutoRenew billingAutoRenew
billedLastAt billedLastAt
billPaidUntil
baseCost baseCost
userId userId
desc desc

View File

@ -88,6 +88,17 @@ export const TERRITORY_BILLING_OPTIONS = (labelPrefix) => ({
} }
}) })
export const TERRITORY_PERIOD_COST = (billingType) => {
switch (billingType.toUpperCase()) {
case 'MONTHLY':
return TERRITORY_COST_MONTHLY
case 'YEARLY':
return TERRITORY_COST_YEARLY
case 'ONCE':
return TERRITORY_COST_ONCE
}
}
export const FOUND_BLURBS = [ export const FOUND_BLURBS = [
'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.', 'The harsh frontier is no place for the unprepared. This hat will protect you from the sun, dust, and other elements Mother Nature throws your way.',
'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.', 'A cowboy is nothing without a cowboy hat. Take good care of it, and it will protect you from the sun, dust, and other elements on your journey.',

View File

@ -1,28 +1,30 @@
import { TERRITORY_GRACE_DAYS } from './constants' import { TERRITORY_GRACE_DAYS, TERRITORY_PERIOD_COST } from './constants'
import { datePivot } from './time' import { datePivot, diffDays } from './time'
export function nextBilling (sub) { export function nextBilling (relativeTo, billingType) {
if (!sub || sub.billingType === 'ONCE') return null if (!relativeTo || billingType === 'ONCE') return null
const pivot = sub.billingType === 'MONTHLY' const pivot = billingType === 'MONTHLY'
? { months: 1 } ? { months: 1 }
: { years: 1 } : { years: 1 }
return datePivot(new Date(sub.billedLastAt), pivot) return datePivot(new Date(relativeTo), pivot)
} }
export function nextNextBilling (sub) { export function purchasedType (sub) {
if (!sub || sub.billingType === 'ONCE') return null if (!sub?.billPaidUntil) return 'ONCE'
return diffDays(new Date(sub.billedLastAt), new Date(sub.billPaidUntil)) >= 365 ? 'YEARLY' : 'MONTHLY'
}
const pivot = sub.billingType === 'MONTHLY' export function proratedBillingCost (sub, newBillingType) {
? { months: 2 } if (!sub ||
: { years: 2 } sub.billingType === 'ONCE' ||
sub.billingType === newBillingType.toUpperCase()) return null
return datePivot(new Date(sub.billedLastAt), pivot) return TERRITORY_PERIOD_COST(newBillingType) - TERRITORY_PERIOD_COST(purchasedType(sub))
} }
export function nextBillingWithGrace (sub) { export function nextBillingWithGrace (sub) {
const dueDate = nextBilling(sub)
if (!sub) return null if (!sub) return null
return datePivot(dueDate, { days: TERRITORY_GRACE_DAYS }) return datePivot(new Date(sub.billPaidUntil), { days: TERRITORY_GRACE_DAYS })
} }

View File

@ -33,6 +33,11 @@ export function datePivot (date,
) )
} }
export function diffDays (date1, date2) {
const diffTime = Math.abs(date2 - date1)
return Math.floor(diffTime / (1000 * 60 * 60 * 24))
}
export const dayMonthYear = when => new Date(when).toISOString().slice(0, 10) export const dayMonthYear = when => new Date(when).toISOString().slice(0, 10)
export const dayMonthYearToDate = when => { export const dayMonthYearToDate = when => {
const [year, month, day] = when.split('-') const [year, month, day] = when.split('-')

View File

@ -0,0 +1,48 @@
-- AlterTable
ALTER TABLE "Sub" ADD COLUMN "billPaidUntil" TIMESTAMP(3);
-- we want to denomalize billPaidUntil into a job so that the application
-- doesn't have to concern itself too much with territory billing jobs
-- and think about future state
CREATE OR REPLACE FUNCTION update_territory_billing()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'UPDATE') THEN
-- delete the old job
DELETE FROM pgboss.job
WHERE name = 'territoryBilling'
AND data->>'subName' = OLD."name";
END IF;
IF (NEW."billPaidUntil" IS NOT NULL) THEN
-- create a new job
INSERT INTO pgboss.job (name, data, startafter, keepuntil)
VALUES (
'territoryBilling',
jsonb_build_object('subName', NEW.name),
NEW."billPaidUntil",
NEW."billPaidUntil" + interval '1 day');
END IF;
RETURN NEW;
EXCEPTION WHEN undefined_table THEN
return NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_territory_billing_trigger ON "Sub";
CREATE TRIGGER update_territory_billing_trigger
AFTER INSERT OR UPDATE ON "Sub"
FOR EACH ROW
WHEN (NEW.status = 'ACTIVE')
EXECUTE PROCEDURE update_territory_billing();
-- migrate existing data to have billPaidUntil
UPDATE "Sub"
SET "billPaidUntil" =
(CASE
WHEN "billingType" = 'MONTHLY' THEN "billedLastAt" + interval '1 month'
WHEN "billingType" = 'YEARLY' THEN "billedLastAt" + interval '1 year'
ELSE NULL
END);

View File

@ -476,6 +476,7 @@ model Sub {
billingCost Int billingCost Int
billingAutoRenew Boolean @default(false) billingAutoRenew Boolean @default(false)
billedLastAt DateTime @default(now()) billedLastAt DateTime @default(now())
billPaidUntil DateTime?
moderated Boolean @default(false) moderated Boolean @default(false)
moderatedCount Int @default(0) moderatedCount Int @default(0)
nsfw Boolean @default(false) nsfw Boolean @default(false)