make territory billing renewal opt-in

This commit is contained in:
keyan 2023-12-08 14:02:00 -06:00
parent 89ecb8c0b9
commit 1d66be68cc
9 changed files with 83 additions and 33 deletions

View File

@ -9,7 +9,8 @@ export default gql`
extend type Mutation { extend type Mutation {
upsertSub(name: String!, desc: String, baseCost: Int!, upsertSub(name: String!, desc: String, baseCost: Int!,
postTypes: [String!]!, billingType: String!, hash: String, hmac: String): Sub postTypes: [String!]!, billingType: String!, billingAutoRenew: Boolean!,
hash: String, hmac: String): Sub
paySub(name: String!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): Sub
} }
@ -23,6 +24,7 @@ export default gql`
postTypes: [String!]! postTypes: [String!]!
billingCost: Int! billingCost: Int!
billingType: String! billingType: String!
billingAutoRenew: Boolean!
rankingType: String! rankingType: String!
billedLastAt: Date! billedLastAt: Date!
baseCost: Int! baseCost: Int!

View File

@ -15,9 +15,11 @@ export default function TerritoryForm ({ sub }) {
const [upsertSub] = useMutation( const [upsertSub] = useMutation(
gql` gql`
mutation upsertSub($name: String!, $desc: String, $baseCost: Int!, mutation upsertSub($name: String!, $desc: String, $baseCost: Int!,
$postTypes: [String!]!, $billingType: String!, $hash: String, $hmac: String) { $postTypes: [String!]!, $billingType: String!, $billingAutoRenew: Boolean!,
$hash: String, $hmac: String) {
upsertSub(name: $name, desc: $desc, baseCost: $baseCost, upsertSub(name: $name, desc: $desc, baseCost: $baseCost,
postTypes: $postTypes, billingType: $billingType, hash: $hash, hmac: $hmac) { postTypes: $postTypes, billingType: $billingType,
billingAutoRenew: $billingAutoRenew, hash: $hash, hmac: $hmac) {
name name
} }
}` }`
@ -59,7 +61,8 @@ export default function TerritoryForm ({ sub }) {
desc: sub?.desc || '', desc: sub?.desc || '',
baseCost: sub?.baseCost || 10, baseCost: sub?.baseCost || 10,
postTypes: sub?.postTypes || POST_TYPES, postTypes: sub?.postTypes || POST_TYPES,
billingType: sub?.billingType || 'MONTHLY' billingType: sub?.billingType || 'MONTHLY',
billingAutoRenew: sub?.billingAutoRenew || false
}} }}
schema={territorySchema({ client, me })} schema={territorySchema({ client, me })}
invoiceable invoiceable
@ -138,7 +141,11 @@ export default function TerritoryForm ({ sub }) {
</Col> </Col>
</Row> </Row>
</CheckboxGroup> </CheckboxGroup>
<CheckboxGroup label={sub ? <>name <small className='text-muted ms-2'>read only</small></> : 'billing'} name='billing'> <CheckboxGroup
label='billing'
name='billing'
groupClassName='mb-0'
>
{(!sub?.billingType || sub.billingType === 'MONTHLY') && {(!sub?.billingType || sub.billingType === 'MONTHLY') &&
<Checkbox <Checkbox
type='radio' type='radio'
@ -170,6 +177,12 @@ export default function TerritoryForm ({ sub }) {
groupClassName='ms-1 mb-0' groupClassName='ms-1 mb-0'
/>} />}
</CheckboxGroup> </CheckboxGroup>
{billing !== 'once' &&
<Checkbox
label='auto renew'
name='billingAutoRenew'
groupClassName='ms-1 mt-2'
/>}
<div className='mt-3 d-flex justify-content-end'> <div className='mt-3 d-flex justify-content-end'>
<FeeButton <FeeButton
text={sub ? 'save' : 'found it'} text={sub ? 'save' : 'found it'}

View File

@ -3,23 +3,29 @@ import { useMe } from './me'
import FeeButton, { FeeButtonProvider } from './fee-button' import FeeButton, { FeeButtonProvider } from './fee-button'
import { TERRITORY_BILLING_OPTIONS, TERRITORY_GRACE_DAYS } from '../lib/constants' import { TERRITORY_BILLING_OPTIONS, TERRITORY_GRACE_DAYS } from '../lib/constants'
import { Form } from './form' import { Form } from './form'
import { datePivot } from '../lib/time' import { datePivot, timeSince } from '../lib/time'
import { LongCountdown } from './countdown' 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'
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)
}
export default function TerritoryPaymentDue ({ sub }) { export default function TerritoryPaymentDue ({ sub }) {
const me = useMe() const me = useMe()
const client = useApolloClient() const client = useApolloClient()
const [paySub] = useMutation(SUB_PAY) const [paySub] = useMutation(SUB_PAY)
const dueDate = datePivot(
new Date(sub.billedLastAt),
sub.billingType === 'MONTHLY'
? { months: 1, days: TERRITORY_GRACE_DAYS }
: { years: 1, days: TERRITORY_GRACE_DAYS })
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ ...variables }) => { async ({ ...variables }) => {
const { error } = await paySub({ const { error } = await paySub({
@ -33,6 +39,10 @@ export default function TerritoryPaymentDue ({ sub }) {
if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null if (!sub || sub.userId !== Number(me?.id) || sub.status === 'ACTIVE') return null
const dueDate = billingDueDate(sub, true)
if (!dueDate) return null
return ( return (
<Alert key='danger' variant='danger'> <Alert key='danger' variant='danger'>
{sub.status === 'STOPPED' {sub.status === 'STOPPED'
@ -75,3 +85,17 @@ export default function TerritoryPaymentDue ({ sub }) {
</Alert> </Alert>
) )
} }
export function TerritoryBillingLine ({ sub }) {
const me = useMe()
if (!sub || sub.userId !== Number(me?.id)) return null
const dueDate = billingDueDate(sub, false)
return (
<div className='text-muted'>
<span>billing {sub.billingAutoRenew ? 'automatically renews' : 'due'} on </span>
<span className='fw-bold'>{dueDate ? timeSince(dueDate) : 'never again'}</span>
</div>
)
}

View File

@ -9,6 +9,7 @@ export const SUB_FIELDS = gql`
rankingType rankingType
billingType billingType
billingCost billingCost
billingAutoRenew
billedLastAt billedLastAt
baseCost baseCost
userId userId

View File

@ -1,6 +1,6 @@
export function timeSince (timeStamp) { export function timeSince (timeStamp) {
const now = new Date() const now = new Date()
const secondsPast = (now.getTime() - timeStamp) / 1000 const secondsPast = Math.abs(now.getTime() - timeStamp) / 1000
if (secondsPast < 60) { if (secondsPast < 60) {
return parseInt(secondsPast) + 's' return parseInt(secondsPast) + 's'
} }

View File

@ -15,7 +15,7 @@ import PageLoading from '../../components/page-loading'
import CardFooter from 'react-bootstrap/CardFooter' import CardFooter from 'react-bootstrap/CardFooter'
import Hat from '../../components/hat' import Hat from '../../components/hat'
import styles from '../../components/item.module.css' import styles from '../../components/item.module.css'
import TerritoryPaymentDue from '../../components/territory-payment-due' import TerritoryPaymentDue, { TerritoryBillingLine } from '../../components/territory-payment-due'
import Badge from 'react-bootstrap/Badge' import Badge from 'react-bootstrap/Badge'
import { numWithUnits } from '../../lib/format' import { numWithUnits } from '../../lib/format'
@ -49,17 +49,16 @@ export default function Sub ({ ssrData }) {
</div> </div>
<CardFooter className={`py-1 ${styles.other}`}> <CardFooter className={`py-1 ${styles.other}`}>
<div className='text-muted'> <div className='text-muted'>
<span>founded by</span> <span>founded by </span>
<span> </span>
<Link href={`/${sub.user.name}`}> <Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} /> @{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
</Link> </Link>
</div> </div>
<div className='text-muted'> <div className='text-muted'>
<span>post cost</span> <span>post cost </span>
<span> </span>
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span> <span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
</div> </div>
<TerritoryBillingLine sub={sub} />
</CardFooter> </CardFooter>
</AccordianCard> </AccordianCard>
</div> </div>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Sub" ADD COLUMN "billingAutoRenew" BOOLEAN NOT NULL DEFAULT false;

View File

@ -403,15 +403,16 @@ model Sub {
parentName String? @db.Citext parentName String? @db.Citext
path Unsupported("ltree")? path Unsupported("ltree")?
postTypes PostType[] postTypes PostType[]
rankingType RankingType rankingType RankingType
baseCost Int @default(1) baseCost Int @default(1)
rewardsPct Int @default(50) rewardsPct Int @default(50)
desc String? desc String?
status Status @default(ACTIVE) status Status @default(ACTIVE)
billingType BillingType billingType BillingType
billingCost Int billingCost Int
billedLastAt DateTime @default(now()) billingAutoRenew Boolean @default(false)
billedLastAt DateTime @default(now())
parent Sub? @relation("ParentChildren", fields: [parentName], references: [name]) parent Sub? @relation("ParentChildren", fields: [parentName], references: [name])
children Sub[] @relation("ParentChildren") children Sub[] @relation("ParentChildren")

View File

@ -10,12 +10,7 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
} }
}) })
try { async function territoryStatusUpdate () {
const queries = paySubQueries(sub, models)
await serialize(models, ...queries)
} catch (e) {
console.error(e)
await models.sub.update({ await models.sub.update({
where: { where: {
name: subName name: subName
@ -27,4 +22,17 @@ export async function territoryBilling ({ data: { subName }, boss, models }) {
// retry billing in one day // retry billing in one day
await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) }) await boss.send('territoryBilling', { subName }, { startAfter: datePivot(new Date(), { days: 1 }) })
} }
if (!sub.billingAutoRenew) {
await territoryStatusUpdate()
return
}
try {
const queries = paySubQueries(sub, models)
await serialize(models, ...queries)
} catch (e) {
console.error(e)
await territoryStatusUpdate()
}
} }