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/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@outreachgraph/secrets": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"@profullstack/affiliates": "^0.3.0",
"@profullstack/coinpay-onboard": "0.3.1",
"@profullstack/coinpay-onboard": "^0.5.0",
"@profullstack/form-guard": "^0.2.2",
"@profullstack/openprofile": "^0.2.0",
"@profullstack/profile": "0.1.8",
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
userForCoinpay,
type CoinpaySignInConfig,
} from './coinpay-signin';
import { applyCoinpayPass } from './coinpay-pass';
import {
INVITE_COOKIE,
REF_COOKIE,
Expand Down Expand Up @@ -1566,7 +1567,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
const code = c.req.query('code');
if (!code) return c.text('CoinPay sign-in failed: no code.', 400);

let who: { sub: string; email: string | null; name: string | null; openprofile_url?: string };
let who: Awaited<ReturnType<typeof finishCoinpaySignIn>>;
try {
who = await finishCoinpaySignIn(config, {
code,
Expand All @@ -1584,6 +1585,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
return c.redirect('/login?error=coinpay_signin_first', 302);
}
const linked = await linkCoinpay(options.db, actor.userId, who);
if (linked.ok) await applyCoinpayPass(options.db, actor.userId, who);
if (linked.ok && who.openprofile_url) {
try {
await saveOpenProfileContact(profiles, actor.userId, who.openprofile_url);
Expand All @@ -1604,6 +1606,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
}

const { userId, created } = await userForCoinpay(options.db, who);
await applyCoinpayPass(options.db, userId, who);
const session = await startSession(options.db, userId, c.req.header('user-agent'));
await repo.audit(options.db, {
workspaceId: session.actor.workspaceId,
Expand Down
70 changes: 70 additions & 0 deletions apps/api/src/coinpay-pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { passFromUserinfo } from '@profullstack/coinpay-onboard/pass';
import { creditsFor } from '@outreachgraph/pipeline';
import { CREDIT_PACKS } from '@outreachgraph/domain';
import { applyCoinpayPass, grantCoinpayPass, passPaymentId, PASS_CREDITS } from './coinpay-pass';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';

let active: SeededDatabase | undefined;
afterEach(() => {
active?.cleanup();
active = undefined;
});

const DAY = 86_400_000;
const inDays = (d: number) => new Date(Date.now() + d * DAY).toISOString();
const claim = (pass: unknown) => passFromUserinfo({ pass });

describe('Profullstack Pass', () => {
test('grants the smallest pack', () => {
expect(PASS_CREDITS).toBe(Math.min(...CREDIT_PACKS.map((p) => p.credits)));
expect(PASS_CREDITS).toBe(100);
});

test('only an active, unexpired pass has a key', () => {
const end = inDays(30);
expect(passPaymentId('sub1', claim({ active: true, expires_at: end }))).toBe(
`coinpay-pass:sub1:${new Date(end).toISOString()}`,
);
expect(passPaymentId('sub1', claim({ active: false, expires_at: end }))).toBeNull();
expect(passPaymentId('sub1', claim({ active: true, expires_at: 'x' }))).toBeNull();
expect(passPaymentId('sub1', claim({ active: true, expires_at: inDays(-1) }))).toBeNull();
expect(passPaymentId('', claim({ active: true, expires_at: end }))).toBeNull();
});

test('credits the home organization once per pass', async () => {
const seeded = await seedDatabase('coinpay-pass');
active = seeded;
const before = (await creditsFor(seeded.db, SEED.organizationId)).remaining;
const pass = claim({ active: true, expires_at: inDays(30) });

const first = await grantCoinpayPass(seeded.db, SEED.userId, 'sub-pass', pass);
expect(first).toEqual({
granted: true,
organizationId: SEED.organizationId,
credits: PASS_CREDITS,
});
const again = await applyCoinpayPass(seeded.db, SEED.userId, { sub: 'sub-pass', pass });
expect(again).toEqual({ granted: false, reason: 'already_granted' });
expect((await creditsFor(seeded.db, SEED.organizationId)).remaining).toBe(
before + PASS_CREDITS,
);

const next = claim({ active: true, expires_at: inDays(60) });
expect((await grantCoinpayPass(seeded.db, SEED.userId, 'sub-pass', next)).granted).toBe(true);
expect((await creditsFor(seeded.db, SEED.organizationId)).remaining).toBe(
before + 2 * PASS_CREDITS,
);
});

test('no pass, or no organization, grants nothing', async () => {
const seeded = await seedDatabase('coinpay-pass-none');
active = seeded;
expect(await applyCoinpayPass(seeded.db, SEED.userId, { sub: 's' })).toBeNull();
const pass = claim({ active: true, expires_at: inDays(30) });
expect(await grantCoinpayPass(seeded.db, 'usr_nobody', 's', pass)).toEqual({
granted: false,
reason: 'no_organization',
});
});
});
78 changes: 78 additions & 0 deletions apps/api/src/coinpay-pass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Profullstack Pass on OutreachGraph: a CoinPay user who deposited $99 into
* their own CoinPay wallet gets a free month on every Profullstack property
* (https://coinpayportal.com/pass).
*
* OutreachGraph sells prospect credits in packs (no plan is purchasable), so
* the pass grants the smallest pack's credits once per pass into the person's
* home organization, through `grantCredits` and the `credit_ledger` CoinPay
* purchases and credit codes already fill. The `payment_id`
* `coinpay-pass:<coinpay sub>:<expires_at>` is unique, so a repeat sign-in
* never pays twice. CoinPay reports the pass only in the userinfo `pass` claim
* at sign-in; the person is the one bound to the CoinPay `sub`
* (userForCoinpay / linkCoinpay), never one found by email.
*/
import type { CoinpayPass } from '@profullstack/coinpay-onboard/pass';
import { CREDIT_PACKS } from '@outreachgraph/domain';
import type { Client } from '@outreachgraph/db';
import { grantCredits } from '@outreachgraph/pipeline';
import { homeOrganization } from './credit-codes';

export { PASS_URL } from '@profullstack/coinpay-onboard/pass';

/** The smallest pack on sale, in prospect credits. */
export const PASS_CREDITS = Math.min(...CREDIT_PACKS.map((pack) => pack.credits));

/** The once-per-pass key, or null when the claim grants nothing. */
export function passPaymentId(
sub: string,
pass: CoinpayPass | null | undefined,
now = Date.now(),
): string | null {
if (!sub || !pass?.active || !pass.expiresAt) return null;
const end = pass.expiresAt.getTime();
if (!Number.isFinite(end) || end <= now) return null;
return `coinpay-pass:${sub}:${pass.expiresAt.toISOString()}`;
}

export type PassGrant =
| { readonly granted: true; readonly organizationId: string; readonly credits: number }
| { readonly granted: false; readonly reason: 'no_pass' | 'no_organization' | 'already_granted' };

/** Grants PASS_CREDITS to the person's home organization, once per pass. */
export async function grantCoinpayPass(
db: Client,
userId: string,
sub: string,
pass: CoinpayPass | null | undefined,
now = Date.now(),
): Promise<PassGrant> {
const paymentId = passPaymentId(sub, pass, now);
if (!paymentId) return { granted: false, reason: 'no_pass' };
const organizationId = await homeOrganization(db, userId);
if (!organizationId) return { granted: false, reason: 'no_organization' };
const fresh = await grantCredits(db, {
organizationId,
credits: PASS_CREDITS,
paymentId,
reason: 'CoinPay Pass',
});
return fresh
? { granted: true, organizationId, credits: PASS_CREDITS }
: { granted: false, reason: 'already_granted' };
}

/** The sign-in step: grant the pass if there is one; never blocks or fails the sign-in. */
export async function applyCoinpayPass(
db: Client,
userId: string,
who: { sub: string; pass?: CoinpayPass },
): Promise<PassGrant | null> {
if (!who.pass?.active) return null;
try {
return await grantCoinpayPass(db, userId, who.sub, who.pass);
} catch (error) {
console.error('coinpay pass grant failed', error instanceof Error ? error.message : error);
return null;
}
}
10 changes: 9 additions & 1 deletion apps/api/src/coinpay-signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
sameState,
SIGN_IN_SCOPES,
} from '@profullstack/coinpay-onboard/oauth';
import type { CoinpayPass } from '@profullstack/coinpay-onboard/pass';
import { now, queryOne, type Client } from '@outreachgraph/db';
import { COINPAY_PLACEHOLDER_DOMAIN, provisionAccount } from './auth';

Expand Down Expand Up @@ -142,7 +143,13 @@ export async function finishCoinpaySignIn(
config: CoinpaySignInConfig,
input: { code: string; verifier: string; nonce: string },
doFetch?: typeof fetch,
): Promise<{ sub: string; email: string | null; name: string | null; openprofile_url?: string }> {
): Promise<{
sub: string;
email: string | null;
name: string | null;
openprofile_url?: string;
pass?: CoinpayPass;
}> {
const base = config.issuer ?? 'https://coinpayportal.com';
const done = await finishSignIn({
code: input.code,
Expand All @@ -158,6 +165,7 @@ export async function finishCoinpaySignIn(
sub: done.sub,
email: done.email,
name: done.name,
...(done.pass?.active ? { pass: done.pass } : {}),
...(typeof done.userinfo.openprofile_url === 'string'
? { openprofile_url: done.userinfo.openprofile_url }
: {}),
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/credit-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ export type BonusStatus =
| 'no_organization';

/** The organization a person's credit goes to: the one they own, oldest first. */
async function homeOrganization(db: Client, userId: string): Promise<string | undefined> {
export async function homeOrganization(db: Client, userId: string): Promise<string | undefined> {
const row = await queryOne<{ organization_id: string }>(
db,
`SELECT organization_id FROM organization_members WHERE user_id = ?
Expand Down
6 changes: 6 additions & 0 deletions apps/web/app/(app)/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export default async function BillingPage() {
<p className="text-ink-muted text-sm">
What your plan covers, and what to do when it runs out.
</p>
<p className="text-ink-muted text-sm">
Deposit $99 in your CoinPay wallet and get a free month:{' '}
<a className="underline" href="https://coinpayportal.com/pass">
CoinPay Pass
</a>
</p>
</header>

{offline || !billing ? (
Expand Down
14 changes: 7 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading