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:
parent
cfd762a5b6
commit
798fab097d
@ -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' } })
|
||||||
|
@ -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!
|
||||||
|
@ -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={
|
||||||
|
@ -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 (
|
||||||
|
@ -12,6 +12,7 @@ export const SUB_FIELDS = gql`
|
|||||||
billingCost
|
billingCost
|
||||||
billingAutoRenew
|
billingAutoRenew
|
||||||
billedLastAt
|
billedLastAt
|
||||||
|
billPaidUntil
|
||||||
baseCost
|
baseCost
|
||||||
userId
|
userId
|
||||||
desc
|
desc
|
||||||
|
@ -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.',
|
||||||
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
@ -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('-')
|
||||||
|
@ -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);
|
||||||
|
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user