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 { 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 { 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 { subViewGroup } from './growth'
@ -12,9 +12,20 @@ export function paySubQueries (sub, models) {
return []
}
const billingAt = nextBilling(sub)
const billAt = nextNextBilling(sub)
const cost = BigInt(sub.billingCost) * BigInt(1000)
// if in active or grace, consider we are billing them from where they are paid up
// and use grandfathered cost
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 [
models.user.update({
@ -33,7 +44,9 @@ export function paySubQueries (sub, models) {
name: sub.name
},
data: {
billedLastAt: billingAt,
billedLastAt,
billPaidUntil,
billingCost,
status: 'ACTIVE'
}
}),
@ -45,18 +58,7 @@ export function paySubQueries (sub, models) {
msats: cost,
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 }) {
const { billingType, nsfw } = data
const { billingType } = data
let billingCost = TERRITORY_COST_MONTHLY
let billAt = datePivot(new Date(), { months: 1 })
let billPaidUntil = datePivot(new Date(), { months: 1 })
if (billingType === 'ONCE') {
billingCost = TERRITORY_COST_ONCE
billAt = null
billPaidUntil = null
} else if (billingType === 'YEARLY') {
billingCost = TERRITORY_COST_YEARLY
billAt = datePivot(new Date(), { years: 1 })
billPaidUntil = datePivot(new Date(), { years: 1 })
}
const cost = BigInt(1000) * BigInt(billingCost)
@ -272,10 +274,10 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
models.sub.create({
data: {
...data,
billPaidUntil,
billingCost,
rankingType: 'WOT',
userId: me.id,
nsfw
userId: me.id
}
}),
// record 'em
@ -286,15 +288,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
msats: cost,
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 })
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 }) {
// prevent modification of billingType
delete data.billingType
const oldSub = await models.sub.findUnique({
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 {
const results = await models.$transaction([
models.sub.update({
data,
where: {
name: oldName,
userId: me.id
}
}),
models.$queryRaw`
UPDATE pgboss.job
SET data = ${JSON.stringify({ subName: data.name })}::JSONB
WHERE name = 'territoryBilling'
AND data->>'subName' = ${oldName}`
])
// 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)
return results[0]
// 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({
data,
where: {
name: oldName,
userId: me.id
}
})
], { models, lnd, hash, hmac, me, enforceFee: proratedCost })
return results[2]
}
}
// 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) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })

View File

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

View File

@ -804,7 +804,7 @@ export function Form ({
const msg = err.message || err.toString?.()
// handle errors from JIT invoices by ignoring them
if (msg === 'modal closed' || msg === 'invoice canceled') return
toaster.danger('submit error:' + msg)
toaster.danger('submit error: ' + msg)
}
}, [onSubmit, feeButton?.total, toaster, clearLocalStorage, storageKeyPrefix])

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 FeeButton, { FeeButtonProvider } from './fee-button'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
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 { useMe } from './me'
import Info from './info'
import { abbrNum } from '../lib/format'
import { purchasedType } from '../lib/territory'
export default function TerritoryForm ({ sub }) {
const router = useRouter()
@ -54,9 +56,24 @@ export default function TerritoryForm ({ sub }) {
)
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 (
<FeeButtonProvider baseLineItems={sub ? undefined : { territory: TERRITORY_BILLING_OPTIONS('first')[billing] }}>
<FeeButtonProvider baseLineItems={lineItems}>
<Form
initial={{
name: sub?.name || '',
@ -74,14 +91,15 @@ export default function TerritoryForm ({ sub }) {
onSubmit={onSubmit}
className='mb-5'
storageKeyPrefix={sub ? undefined : 'territory'}
> <Input
label='name'
name='name'
required
autoFocus
clear
maxLength={32}
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
>
<Input
label='name'
name='name'
required
autoFocus
clear
maxLength={32}
prepend={<InputGroup.Text className='text-monospace'>~</InputGroup.Text>}
/>
<MarkdownInput
label='description'
@ -147,51 +165,56 @@ export default function TerritoryForm ({ sub }) {
</Col>
</Row>
</CheckboxGroup>
<CheckboxGroup
label='billing'
name='billing'
groupClassName='mb-0'
>
{(!sub?.billingType || sub.billingType === 'MONTHLY') &&
<Checkbox
type='radio'
label='100k sats/month'
value='MONTHLY'
name='billingType'
id='monthly-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('monthly')}
groupClassName='ms-1 mb-0'
/>}
{(!sub?.billingType || sub.billingType === 'YEARLY') &&
<Checkbox
type='radio'
label='1m sats/year'
value='YEARLY'
name='billingType'
id='yearly-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('yearly')}
groupClassName='ms-1 mb-0'
/>}
{(!sub?.billingType || sub.billingType === 'ONCE') &&
<Checkbox
type='radio'
label='3m sats once'
value='ONCE'
name='billingType'
id='once-checkbox'
readOnly={!!sub}
handleChange={checked => checked && setBilling('once')}
groupClassName='ms-1 mb-0'
/>}
</CheckboxGroup>
{billing !== 'once' &&
<Checkbox
label='auto renew'
name='billingAutoRenew'
groupClassName='ms-1 mt-2'
/>}
{sub?.billingType !== 'ONCE' &&
<>
<CheckboxGroup
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'
groupClassName={billing !== 'once' ? 'mb-0' : ''}
>
<Checkbox
type='radio'
label='100k sats/month'
value='MONTHLY'
name='billingType'
id='monthly-checkbox'
handleChange={checked => checked && setBilling('monthly')}
groupClassName='ms-1 mb-0'
/>
<Checkbox
type='radio'
label='1m sats/year'
value='YEARLY'
name='billingType'
id='yearly-checkbox'
handleChange={checked => checked && setBilling('yearly')}
groupClassName='ms-1 mb-0'
/>
<Checkbox
type='radio'
label='3m sats once'
value='ONCE'
name='billingType'
id='once-checkbox'
handleChange={checked => checked && setBilling('once')}
groupClassName='ms-1 mb-0'
/>
</CheckboxGroup>
{billing !== 'once' &&
<Checkbox
label='auto-renew'
name='billingAutoRenew'
groupClassName='ms-1 mt-2'
/>}
</>}
<AccordianItem
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>}
body={

View File

@ -8,7 +8,7 @@ import { LongCountdown } from './countdown'
import { useCallback } from 'react'
import { useApolloClient, useMutation } from '@apollo/client'
import { SUB_PAY } from '../fragments/subs'
import { nextBilling, nextBillingWithGrace } from '../lib/territory'
import { nextBillingWithGrace } from '../lib/territory'
export default function TerritoryPaymentDue ({ sub }) {
const me = useMe()
@ -78,7 +78,7 @@ export function TerritoryBillingLine ({ sub }) {
const me = useMe()
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()
return (

View File

@ -12,6 +12,7 @@ export const SUB_FIELDS = gql`
billingCost
billingAutoRenew
billedLastAt
billPaidUntil
baseCost
userId
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 = [
'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.',

View File

@ -1,28 +1,30 @@
import { TERRITORY_GRACE_DAYS } from './constants'
import { datePivot } from './time'
import { TERRITORY_GRACE_DAYS, TERRITORY_PERIOD_COST } from './constants'
import { datePivot, diffDays } from './time'
export function nextBilling (sub) {
if (!sub || sub.billingType === 'ONCE') return null
export function nextBilling (relativeTo, billingType) {
if (!relativeTo || billingType === 'ONCE') return null
const pivot = sub.billingType === 'MONTHLY'
const pivot = billingType === 'MONTHLY'
? { months: 1 }
: { years: 1 }
return datePivot(new Date(sub.billedLastAt), pivot)
return datePivot(new Date(relativeTo), pivot)
}
export function nextNextBilling (sub) {
if (!sub || sub.billingType === 'ONCE') return null
export function purchasedType (sub) {
if (!sub?.billPaidUntil) return 'ONCE'
return diffDays(new Date(sub.billedLastAt), new Date(sub.billPaidUntil)) >= 365 ? 'YEARLY' : 'MONTHLY'
}
const pivot = sub.billingType === 'MONTHLY'
? { months: 2 }
: { years: 2 }
export function proratedBillingCost (sub, newBillingType) {
if (!sub ||
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) {
const dueDate = nextBilling(sub)
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 dayMonthYearToDate = when => {
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
billingAutoRenew Boolean @default(false)
billedLastAt DateTime @default(now())
billPaidUntil DateTime?
moderated Boolean @default(false)
moderatedCount Int @default(0)
nsfw Boolean @default(false)