diff --git a/apps/api/package.json b/apps/api/package.json index ca32d4e..a69f963 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dcaaf26..4ffc8ee 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -81,6 +81,7 @@ import { userForCoinpay, type CoinpaySignInConfig, } from './coinpay-signin'; +import { applyCoinpayPass } from './coinpay-pass'; import { INVITE_COOKIE, REF_COOKIE, @@ -1566,7 +1567,7 @@ export function createApp(options: AppOptions): Hono { 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>; try { who = await finishCoinpaySignIn(config, { code, @@ -1584,6 +1585,7 @@ export function createApp(options: AppOptions): Hono { 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); @@ -1604,6 +1606,7 @@ export function createApp(options: AppOptions): Hono { } 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, diff --git a/apps/api/src/coinpay-pass.test.ts b/apps/api/src/coinpay-pass.test.ts new file mode 100644 index 0000000..5cc1c25 --- /dev/null +++ b/apps/api/src/coinpay-pass.test.ts @@ -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', + }); + }); +}); diff --git a/apps/api/src/coinpay-pass.ts b/apps/api/src/coinpay-pass.ts new file mode 100644 index 0000000..c19faa2 --- /dev/null +++ b/apps/api/src/coinpay-pass.ts @@ -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::` 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/coinpay-signin.ts b/apps/api/src/coinpay-signin.ts index fddf9b1..1eae19f 100644 --- a/apps/api/src/coinpay-signin.ts +++ b/apps/api/src/coinpay-signin.ts @@ -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'; @@ -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, @@ -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 } : {}), diff --git a/apps/api/src/credit-codes.ts b/apps/api/src/credit-codes.ts index f0d6490..6e5a558 100644 --- a/apps/api/src/credit-codes.ts +++ b/apps/api/src/credit-codes.ts @@ -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 { +export async function homeOrganization(db: Client, userId: string): Promise { const row = await queryOne<{ organization_id: string }>( db, `SELECT organization_id FROM organization_members WHERE user_id = ? diff --git a/apps/web/app/(app)/billing/page.tsx b/apps/web/app/(app)/billing/page.tsx index 9acb0ad..d077e7c 100644 --- a/apps/web/app/(app)/billing/page.tsx +++ b/apps/web/app/(app)/billing/page.tsx @@ -39,6 +39,12 @@ export default async function BillingPage() {

What your plan covers, and what to do when it runs out.

+

+ Deposit $99 in your CoinPay wallet and get a free month:{' '} + + CoinPay Pass + +

{offline || !billing ? ( diff --git a/bun.lock b/bun.lock index 25a31d8..fae9628 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "apps/api": { "name": "@outreachgraph/api", - "version": "0.25.7", + "version": "0.27.1", "dependencies": { "@logicsrc/openaccess": "^0.3.1", "@outreachgraph/ai": "workspace:*", @@ -29,7 +29,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", @@ -41,7 +41,7 @@ }, "apps/cli": { "name": "@profullstack/outreachgraph", - "version": "0.25.7", + "version": "0.27.1", "bin": { "og": "./dist/og.js", }, @@ -52,7 +52,7 @@ }, "apps/mcp": { "name": "@outreachgraph/mcp", - "version": "0.25.7", + "version": "0.27.1", "bin": { "outreachgraph-mcp": "./src/index.ts", }, @@ -63,7 +63,7 @@ }, "apps/server": { "name": "@outreachgraph/server", - "version": "0.25.7", + "version": "0.27.1", "dependencies": { "@outreachgraph/ai": "workspace:*", "@outreachgraph/contracts": "workspace:*", @@ -85,7 +85,7 @@ }, "apps/web": { "name": "@outreachgraph/web", - "version": "0.25.7", + "version": "0.27.1", "dependencies": { "@outreachgraph/contracts": "workspace:*", "@outreachgraph/domain": "workspace:*", @@ -434,7 +434,7 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], - "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.3.1", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ=="], + "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ=="], "@profullstack/email-queue": ["@profullstack/email-queue@0.2.1", "", { "dependencies": { "bullmq": "^5.58.0", "ioredis": "^5.4.0", "nodemailer": "^10.3.1" } }, "sha512-2rb95Tlhoa2RTzqTYmgV5+OHJxtevxeMVKsUZS5vmYmeuCyClVin0r3Gwgydfh4mPK7Zg6pYvYSm18xHhObBKQ=="],