diff --git a/apps/cli/package.json b/apps/cli/package.json index c25b424..34465c6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/nichedb", - "version": "0.35.8", + "version": "0.36.0", "description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment", "type": "module", "main": "src/index.js", diff --git a/apps/cli/src/index.js b/apps/cli/src/index.js index 5e887b8..d0f4ae0 100644 --- a/apps/cli/src/index.js +++ b/apps/cli/src/index.js @@ -15,7 +15,7 @@ import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; -export const VERSION = '0.35.8'; +export const VERSION = '0.36.0'; const GEO_FLAGS = ['lat', 'long', 'radius', 'unit', 'bbox', 'sort', 'offset']; const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev'; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb'); diff --git a/apps/web/package.json b/apps/web/package.json index 4d4fec9..988339d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/web", - "version": "0.35.8", + "version": "0.36.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/lib/premium-checkout.js b/apps/web/src/lib/premium-checkout.js index f176fdb..173fdf6 100644 --- a/apps/web/src/lib/premium-checkout.js +++ b/apps/web/src/lib/premium-checkout.js @@ -4,6 +4,7 @@ import * as pay from '@nichedb/payments'; import { MEMBERSHIP_KIND } from '@nichedb/payments/membership'; import { priceFor } from '@nichedb/payments/referrals'; import { termById } from '@nichedb/premium'; +import { findPromo, parsePromoCodes, promoDiscountCents } from '@nichedb/premium/promo'; import { Denied } from './service.js'; export const prices = () => ({ @@ -12,31 +13,69 @@ export const prices = () => ({ yearCents: config.premium.yearCents, }); +/** The promo a typed code names on this deployment right now, or null. */ +export const promoFor = (typed) => + typed ? findPromo(parsePromoCodes(config.premium.promoCodes), typed) : null; + +/** + * What one term costs this buyer: the better of their referral and the promo, + * never both. A referral pays its affiliate out of the price, so stacking the + * two would sell below what the commission is worth. When the promo wins, no + * referral is attached and no commission is owed. + */ +export async function quotePremium(user, term, promo, { referralPrice = priceFor } = {}) { + const referral = user + ? await referralPrice(sql, { + userId: user.id, + referredBy: user.referred_by, + amountCents: term.cents, + }).catch(() => null) + : null; + const referralOff = referral?.discountCents ?? 0; + const promoOff = promoDiscountCents(term.cents, promo); + if (promo && promoOff >= referralOff) + return { + amountCents: term.cents - promoOff, + discountCents: promoOff, + referralCode: null, + promoCode: promo.code, + promoPercent: promo.percent, + }; + return { + amountCents: referral?.amountCents ?? term.cents, + discountCents: referralOff, + referralCode: referral?.code ?? null, + promoCode: null, + promoPercent: 0, + }; +} + /** Every Premium term buys the same account entitlements, including a single day. */ export async function startPremiumCheckout( user, wanted, - { createCheckout = pay.createCheckout } = {}, + { createCheckout = pay.createCheckout, promoCode = '', referralPrice } = {}, ) { if (!config.premium.enabled) throw new Denied('Payments are not configured on this deployment.', 400); const term = termById(wanted, prices()); if (!term) throw new Denied('No such term.', 400); - const price = await priceFor(sql, { - userId: user.id, - referredBy: user.referred_by, - amountCents: term.cents, - }); + const promo = promoFor(promoCode); + if (promoCode && !promo) throw new Denied('That promo code is not valid.', 400); + const price = await quotePremium(user, term, promo, { referralPrice }); return createCheckout({ user, amountCents: price.amountCents, currency: config.premium.currency, - description: `${config.siteName} Premium, ${term.days} days`, + description: `${config.siteName} Premium, ${term.days} days${ + price.promoCode ? ` (${price.promoCode}, ${price.promoPercent}% off)` : '' + }`, metadata: { kind: MEMBERSHIP_KIND, plan: 'premium', term_days: String(term.days), - referral_code: price.code ?? '', + referral_code: price.referralCode ?? '', + promo_code: price.promoCode ?? '', list_price_cents: String(term.cents), }, blockchain: config.payments.blockchain, diff --git a/apps/web/src/routes/premium.js b/apps/web/src/routes/premium.js index c22e733..a85ede3 100644 --- a/apps/web/src/routes/premium.js +++ b/apps/web/src/routes/premium.js @@ -11,7 +11,6 @@ import { config } from '@nichedb/config'; import { sql } from '@nichedb/db'; import * as premiumDb from '@nichedb/db/premium'; import * as q from '@nichedb/db/queries'; -import { priceFor } from '@nichedb/payments/referrals'; import { APP_ICONS, awardCost, @@ -25,6 +24,7 @@ import { termOptions, } from '@nichedb/premium'; import { comparisonRows, REDDIT } from '@nichedb/premium/comparison'; +import { promoDiscountCents } from '@nichedb/premium/promo'; import { render, requireUser, respond, wantsJson } from '../lib/http.js'; import { awardCheck, @@ -34,7 +34,7 @@ import { premiumSnapshot, requirePremium, } from '../lib/premium.js'; -import { prices, startPremiumCheckout } from '../lib/premium-checkout.js'; +import { prices, promoFor, quotePremium, startPremiumCheckout } from '../lib/premium-checkout.js'; import { Denied } from '../lib/service.js'; import { LoungePage, PremiumPage } from '../views/premium.jsx'; @@ -55,20 +55,18 @@ export function registerPremium(app, { checkout = startPremiumCheckout } = {}) { app.get('/premium', async (c) => { const user = c.get('user'); const plan = planOf(c); + const typed = c.req.query('promo') ?? ''; + const promo = promoFor(typed); const terms = termOptions(prices()); const quotedTerms = await Promise.all( terms.map(async (term) => { - const quote = user - ? await priceFor(sql, { - userId: user.id, - referredBy: user.referred_by, - amountCents: term.cents, - }).catch(() => null) - : null; + const quote = await quotePremium(user, term, promo).catch(() => null); return { ...term, checkoutCents: quote?.amountCents ?? term.cents, discountCents: quote?.discountCents ?? 0, + promoCode: quote?.promoCode ?? null, + promoPercent: quote?.promoPercent ?? 0, }; }), ); @@ -88,22 +86,38 @@ export function registerPremium(app, { checkout = startPremiumCheckout } = {}) { snapshot={snapshot} selectedTerm={termById(c.req.query('term'), prices())?.id ?? 'month'} enabled={config.premium.enabled} + promo={promo} + promoTyped={typed} notice={c.req.query('notice')} - error={c.req.query('error')} + error={ + c.req.query('error') ?? (typed && !promo ? 'That promo code is not valid.' : undefined) + } />, ), ); }); /** Membership pricing and entitlements, distinct from account-free crawl access. */ - app.get('/api/v1/premium', (c) => - c.json({ + app.get('/api/v1/premium', (c) => { + const promo = promoFor(c.req.query('promo')); + return c.json({ plan: planOf(c), entitlements: entitlementsOf(c), pricing: { currency: config.premium.currency, - terms: termOptions(prices()), + terms: termOptions(prices()).map((term) => + promo + ? { + ...term, + promo_code: promo.code, + checkout_cents: term.cents - promoDiscountCents(term.cents, promo), + } + : term, + ), purchase: `${config.siteUrl}/api/premium/buy`, + promo: promo + ? { code: promo.code, percent: promo.percent, ends: promo.ends?.toISOString() ?? null } + : null, renews: false, data: { cents: config.dataDumps.priceCents, @@ -123,8 +137,8 @@ export function registerPremium(app, { checkout = startPremiumCheckout } = {}) { sources: REDDIT.sources.map((s) => s.url), rows: rowsForSite(), }, - }), - ); + }); + }); /* ---------------------------------------------------------------- buying -- */ @@ -134,7 +148,8 @@ export function registerPremium(app, { checkout = startPremiumCheckout } = {}) { ? await c.req.json().catch(() => ({})) : await c.req.parseBody().catch(() => ({})); const wanted = String(body?.term ?? c.req.query('term') ?? 'month'); - const { checkoutUrl } = await checkout(user, wanted); + const promoCode = String(body?.promo ?? c.req.query('promo') ?? ''); + const { checkoutUrl } = await checkout(user, wanted, { promoCode }); // Keep the provider's origin: the generic respond() helper only redirects locally. return wantsJson(c) ? c.json({ checkoutUrl }) : c.redirect(checkoutUrl, 303); }); diff --git a/apps/web/src/routes/premium.test.js b/apps/web/src/routes/premium.test.js index dbe090e..4ce7a93 100644 --- a/apps/web/src/routes/premium.test.js +++ b/apps/web/src/routes/premium.test.js @@ -28,8 +28,9 @@ afterEach(() => { function build(user = USER) { const calls = []; - const checkout = (buyer, term) => + const checkout = (buyer, term, opts = {}) => startPremiumCheckout(buyer, term, { + ...opts, createCheckout: async (args) => { calls.push(args); return { checkoutUrl: CHECKOUT }; @@ -59,6 +60,67 @@ const json = (body) => ({ }); describe('Premium checkout', () => { + test('promo code 50OFF halves every term and is recorded on the payment', async () => { + const { app, calls } = build(); + for (const [term, cents] of [ + ['day', prices().dayCents], + ['month', prices().monthCents], + ['year', prices().yearCents], + ]) { + const res = await app.request('/api/premium/buy', { + method: 'POST', + body: new URLSearchParams({ term, promo: '50off' }), + }); + expect(res.status).toBe(303); + const purchase = calls.at(-1); + expect(purchase.amountCents).toBe(cents / 2); + expect(purchase.metadata).toMatchObject({ + promo_code: '50OFF', + referral_code: '', + list_price_cents: String(cents), + }); + } + }); + test('an unknown promo code is refused rather than charged at full price', async () => { + const { app, calls } = build(); + const res = await app.request('/api/premium/buy', json({ term: 'month', promo: 'NOPE' })); + expect(res.status).toBe(400); + expect(calls).toEqual([]); + }); + test('promo and referral never stack: the bigger discount wins alone', async () => { + const calls = []; + const referralPrice = async (_sql, { amountCents }) => ({ + amountCents: amountCents - amountCents * 0.2, + discountCents: amountCents * 0.2, + code: 'FRIEND', + }); + const createCheckout = async (args) => { + calls.push(args); + return { checkoutUrl: CHECKOUT }; + }; + const buyer = { ...USER, referred_by: 'FRIEND' }; + await startPremiumCheckout(buyer, 'month', { + promoCode: '50OFF', + referralPrice, + createCheckout, + }); + expect(calls[0].amountCents).toBe(prices().monthCents / 2); + expect(calls[0].metadata.referral_code).toBe(''); + await startPremiumCheckout(buyer, 'month', { referralPrice, createCheckout }); + expect(calls[1].amountCents).toBe(prices().monthCents * 0.8); + expect(calls[1].metadata).toMatchObject({ referral_code: 'FRIEND', promo_code: '' }); + }); + test('the pricing page and API quote the promo', async () => { + const { app } = build(null); + const html = await (await app.request('/premium?promo=50OFF')).text(); + expect(html).toContain('50OFF applied'); + const api = await (await app.request('/api/v1/premium?promo=50OFF')).json(); + expect(api.pricing.promo).toMatchObject({ code: '50OFF', percent: 50 }); + const month = api.pricing.terms.find((t) => t.id === 'month'); + expect(month.checkout_cents).toBe(prices().monthCents / 2); + const bad = await (await app.request('/premium?promo=NOPE')).text(); + expect(bad).toContain('That promo code is not valid.'); + }); test('all three forms buy membership and redirect to the actual payment provider', async () => { const { app, calls } = build(); for (const [term, days, cents] of [ diff --git a/apps/web/src/views/premium.jsx b/apps/web/src/views/premium.jsx index 99baa76..5845575 100644 --- a/apps/web/src/views/premium.jsx +++ b/apps/web/src/views/premium.jsx @@ -71,7 +71,14 @@ const TermCard = ({ term, user, enabled, selected }) => {
- {money(term.cents)} {term.label}
+ {term.promoCode ? (
+ <>
+ {money(term.cents)} {money(term.checkoutCents)}{' '}
+ >
+ ) : (
+ <>{money(term.cents)} >
+ )}
+ {term.label}
${(term.perDayCents / 100).toFixed(2)}/day · {durations[term.id]} of Premium @@ -81,7 +88,11 @@ const TermCard = ({ term, user, enabled, selected }) => { ? `Keep ${money(term.savedCents)} compared with buying ${term.days} individual days.` : 'Every Premium perk. One upfront payment.'}
- {term.discountCents > 0 ? ( + {term.promoCode ? ( ++ {term.promoCode}: {term.promoPercent}% off, you save {money(term.discountCents)}. +
+ ) : term.discountCents > 0 ? (Your referral saves {money(term.discountCents)} on this term.
@@ -90,6 +101,7 @@ const TermCard = ({ term, user, enabled, selected }) => { user ? (