Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/web",
"version": "0.35.8",
"version": "0.36.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
55 changes: 47 additions & 8 deletions apps/web/src/lib/premium-checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => ({
Expand All @@ -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,
Expand Down
47 changes: 31 additions & 16 deletions apps/web/src/routes/premium.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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';

Expand All @@ -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,
};
}),
);
Expand All @@ -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,
Expand All @@ -123,8 +137,8 @@ export function registerPremium(app, { checkout = startPremiumCheckout } = {}) {
sources: REDDIT.sources.map((s) => s.url),
rows: rowsForSite(),
},
}),
);
});
});

/* ---------------------------------------------------------------- buying -- */

Expand All @@ -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);
});
Expand Down
64 changes: 63 additions & 1 deletion apps/web/src/routes/premium.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 [
Expand Down
38 changes: 35 additions & 3 deletions apps/web/src/views/premium.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,14 @@ const TermCard = ({ term, user, enabled, selected }) => {
</p>
<h3>{titles[term.id]}</h3>
<p class="premium-term-price">
{money(term.cents)} <span>{term.label}</span>
{term.promoCode ? (
<>
<s class="muted">{money(term.cents)}</s> {money(term.checkoutCents)}{' '}
</>
) : (
<>{money(term.cents)} </>
)}
<span>{term.label}</span>
</p>
<p class="small muted">
${(term.perDayCents / 100).toFixed(2)}/day · {durations[term.id]} of Premium
Expand All @@ -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.'}
</p>
{term.discountCents > 0 ? (
{term.promoCode ? (
<p class="small premium-referral">
{term.promoCode}: {term.promoPercent}% off, you save {money(term.discountCents)}.
</p>
) : term.discountCents > 0 ? (
<p class="small premium-referral">
Your referral saves {money(term.discountCents)} on this term.
</p>
Expand All @@ -90,14 +101,17 @@ const TermCard = ({ term, user, enabled, selected }) => {
user ? (
<form method="post" action="/api/premium/buy">
<input type="hidden" name="term" value={term.id} />
{term.promoCode ? <input type="hidden" name="promo" value={term.promoCode} /> : null}
<button type="submit" class={selected ? 'cta' : 'ghost'}>
Get {durations[term.id]} · {money(term.checkoutCents ?? term.cents)}
</button>
</form>
) : (
<a
class={`button ${selected ? 'cta' : 'ghost'}`}
href={`/login?next=${encodeURIComponent(`/premium?term=${term.id}#plans`)}`}
href={`/login?next=${encodeURIComponent(
`/premium?term=${term.id}${term.promoCode ? `&promo=${term.promoCode}` : ''}#plans`,
)}`}
>
Sign in for {durations[term.id]}
</a>
Expand All @@ -119,6 +133,8 @@ export const PremiumPage = ({
snapshot,
enabled,
selectedTerm = 'month',
promo,
promoTyped = '',
notice,
error,
}) => {
Expand Down Expand Up @@ -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.
</p>
<form method="get" action="/premium#plans" class="row premium-promo">
<input type="hidden" name="term" value={selectedTerm} />
<label class="small" for="promo">
Have a promo code?
</label>
<input id="promo" name="promo" value={promo?.code ?? promoTyped} placeholder="50OFF" />
<button type="submit" class="ghost">
Apply
</button>
{promo ? (
<span class="small ok">
{promo.code} applied: {promo.percent}% off every term
{promo.ends ? `, through ${promo.ends.toISOString().slice(0, 10)}` : ''}.
</span>
) : null}
</form>
<div class="premium-terms">
{terms.map((term) => (
<TermCard
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/worker",
"version": "0.35.8",
"version": "0.36.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading
Loading