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 }) => {

{titles[term.id]}

- {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 ? (
+ {term.promoCode ? : null} @@ -97,7 +109,9 @@ const TermCard = ({ term, user, enabled, selected }) => { ) : ( Sign in for {durations[term.id]} @@ -119,6 +133,8 @@ export const PremiumPage = ({ snapshot, enabled, selectedTerm = 'month', + promo, + promoTyped = '', notice, error, }) => { @@ -282,6 +298,22 @@ export const PremiumPage = ({ The same Premium benefits in every term. Prices in {config.premium.currency}; paid upfront through CoinPay in crypto.

+ + + + + + {promo ? ( + + {promo.code} applied: {promo.percent}% off every term + {promo.ends ? `, through ${promo.ends.toISOString().slice(0, 10)}` : ''}. + + ) : null} +
{terms.map((term) => ( = 100) continue; + const ends = /^\d{4}-\d{2}-\d{2}$/.test(until ?? '') + ? new Date(`${until}T23:59:59.999Z`) + : null; + codes.set(code, { code, percent, ends }); + } + return codes; +} + +export const normalizeCode = (code) => + String(code ?? '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9_-]/g, ''); + +/** The promo a typed code names, or null when it is unknown or over. */ +export function findPromo(codes, typed, now = new Date()) { + const promo = codes.get(normalizeCode(typed)); + if (!promo) return null; + if (promo.ends && promo.ends < now) return null; + return promo; +} + +/** What a promo takes off a price, in whole cents, rounded in the buyer's favour. */ +export const promoDiscountCents = (amountCents, promo) => + promo ? Math.ceil((amountCents * promo.percent) / 100) : 0; diff --git a/packages/premium/src/promo.test.js b/packages/premium/src/promo.test.js new file mode 100644 index 0000000..2dc9b11 --- /dev/null +++ b/packages/premium/src/promo.test.js @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test'; +import { findPromo, parsePromoCodes, promoDiscountCents } from './promo.js'; + +describe('promo codes', () => { + test('parses code, percent and an inclusive UTC end date, skipping junk', () => { + const codes = parsePromoCodes(' 50off:50 , LAUNCH:20:2026-10-31, BAD:0, WORSE:150, :10, X:abc'); + expect([...codes.keys()]).toEqual(['50OFF', 'LAUNCH']); + expect(codes.get('LAUNCH').ends.toISOString()).toBe('2026-10-31T23:59:59.999Z'); + expect(parsePromoCodes('').size).toBe(0); + }); + test('lookup is case-insensitive and an ended code is gone', () => { + const codes = parsePromoCodes('50OFF:50,OLD:30:2026-01-01'); + expect(findPromo(codes, ' 50off ')?.percent).toBe(50); + expect(findPromo(codes, 'OLD', new Date('2026-01-01T12:00:00Z'))?.percent).toBe(30); + expect(findPromo(codes, 'OLD', new Date('2026-01-02T00:00:00Z'))).toBeNull(); + expect(findPromo(codes, 'nope')).toBeNull(); + }); + test('discount is whole cents, rounded toward the buyer', () => { + expect(promoDiscountCents(3000, { percent: 50 })).toBe(1500); + expect(promoDiscountCents(99, { percent: 50 })).toBe(50); + expect(promoDiscountCents(3000, null)).toBe(0); + }); +}); diff --git a/packages/queue/package.json b/packages/queue/package.json index a3d3548..549ba49 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@nichedb/queue", - "version": "0.35.8", + "version": "0.36.0", "private": true, "type": "module", "exports": {