From ebb1db2448bcf98e75219f67072caa7d6f774659 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 1 Jun 2026 22:03:46 +0200 Subject: [PATCH 1/2] feat: add scoped merchant credit mvp --- db/migrations/0011_scoped_credit.sql | 54 ++++ db/schemas.gen.ts | 45 +++ db/types.gen.ts | 52 ++++ src/chat.ts | 140 ++++++++- src/chat.workers.test.ts | 50 ++++ src/lib/scopedCredit.test.ts | 22 ++ src/lib/scopedCredit.ts | 428 +++++++++++++++++++++++++++ 7 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 db/migrations/0011_scoped_credit.sql create mode 100644 src/lib/scopedCredit.test.ts create mode 100644 src/lib/scopedCredit.ts diff --git a/db/migrations/0011_scoped_credit.sql b/db/migrations/0011_scoped_credit.sql new file mode 100644 index 0000000..49768c2 --- /dev/null +++ b/db/migrations/0011_scoped_credit.sql @@ -0,0 +1,54 @@ +-- Adds merchant-scoped credit records for the first Slack MVP. +CREATE TABLE "scoped_credit" ( + "id" TEXT PRIMARY KEY NOT NULL, + "workspace_id" TEXT NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + "idempotency_key" TEXT NOT NULL UNIQUE, + "provider_channel_id" TEXT NOT NULL, + "provider_thread_id" TEXT, + "sender_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "recipient_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, + "sender_provider_user_id" TEXT NOT NULL, + "recipient_provider_user_id" TEXT NOT NULL, + "merchant_id" TEXT NOT NULL, + "merchant_name" TEXT NOT NULL, + "merchant_address" TEXT NOT NULL, + "amount" INTEGER NOT NULL CHECK ("amount" > 0), + "token_address" TEXT NOT NULL, + "status" TEXT NOT NULL CHECK ("status" IN ('pending', 'issued', 'spent', 'canceled', 'expired', 'failed')), + "tempo_transaction_hash" TEXT UNIQUE, + "mpp_receipt_id" TEXT UNIQUE, + "expires_at" TEXT NOT NULL, + "failed_at" TEXT, + "failure_reason" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ("status" = 'failed' OR "failed_at" IS NULL), + CHECK ("status" = 'failed' OR "failure_reason" IS NULL) +); + +CREATE INDEX "scoped_credit_recipient_status_idx" ON "scoped_credit" ( + "recipient_member_id", + "status", + "created_at" +); +CREATE INDEX "scoped_credit_sender_created_idx" ON "scoped_credit" ( + "sender_member_id", + "created_at" +); +CREATE INDEX "scoped_credit_workspace_created_idx" ON "scoped_credit" ( + "workspace_id", + "created_at" +); + +CREATE TABLE "scoped_credit_event" ( + "id" TEXT PRIMARY KEY NOT NULL, + "scoped_credit_id" TEXT NOT NULL REFERENCES "scoped_credit" ("id") ON DELETE CASCADE, + "event_type" TEXT NOT NULL CHECK ("event_type" IN ('created', 'sender_confirmed', 'issued', 'recipient_notified', 'spend_started', 'paid', 'canceled', 'expired', 'failed')), + "details_json" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX "scoped_credit_event_credit_created_idx" ON "scoped_credit_event" ( + "scoped_credit_id", + "created_at" +); diff --git a/db/schemas.gen.ts b/db/schemas.gen.ts index c293a6d..233464d 100644 --- a/db/schemas.gen.ts +++ b/db/schemas.gen.ts @@ -92,6 +92,49 @@ export const reaction_tip_thread = z.object({ workspace_id: z.string(), }) +export const scoped_credit = z.object({ + amount: z.number(), + created_at: z.string(), + expires_at: z.string(), + failed_at: z.string().nullable(), + failure_reason: z.string().nullable(), + id: z.string(), + idempotency_key: z.string(), + merchant_address: z.string(), + merchant_id: z.string(), + merchant_name: z.string(), + mpp_receipt_id: z.string().nullable(), + provider_channel_id: z.string(), + provider_thread_id: z.string().nullable(), + recipient_member_id: z.string(), + recipient_provider_user_id: z.string(), + sender_member_id: z.string(), + sender_provider_user_id: z.string(), + status: z.enum(['pending', 'issued', 'spent', 'canceled', 'expired', 'failed']), + tempo_transaction_hash: z.string().nullable(), + token_address: z.string(), + updated_at: z.string(), + workspace_id: z.string(), +}) + +export const scoped_credit_event = z.object({ + created_at: z.string(), + details_json: z.string().nullable(), + event_type: z.enum([ + 'created', + 'sender_confirmed', + 'issued', + 'recipient_notified', + 'spend_started', + 'paid', + 'canceled', + 'expired', + 'failed', + ]), + id: z.string(), + scoped_credit_id: z.string(), +}) + export const tip = z.object({ access_key_id: z.string().nullable(), amount: z.number(), @@ -167,6 +210,8 @@ export const db = { provider_identity: provider_identity, reaction_tip: reaction_tip, reaction_tip_thread: reaction_tip_thread, + scoped_credit: scoped_credit, + scoped_credit_event: scoped_credit_event, tip: tip, tip_batch: tip_batch, workspace: workspace, diff --git a/db/types.gen.ts b/db/types.gen.ts index 2b28d2b..7b11739 100644 --- a/db/types.gen.ts +++ b/db/types.gen.ts @@ -10,6 +10,8 @@ export interface DB { provider_identity: provider_identity reaction_tip: reaction_tip reaction_tip_thread: reaction_tip_thread + scoped_credit: scoped_credit + scoped_credit_event: scoped_credit_event tip: tip tip_batch: tip_batch workspace: workspace @@ -105,6 +107,48 @@ type reaction_tip_thread = { workspace_id: string } +type scoped_credit = { + amount: number + created_at: k.Generated + expires_at: string + failed_at: string | null + failure_reason: string | null + id: string + idempotency_key: string + merchant_address: string + merchant_id: string + merchant_name: string + mpp_receipt_id: string | null + provider_channel_id: string + provider_thread_id: string | null + recipient_member_id: string + recipient_provider_user_id: string + sender_member_id: string + sender_provider_user_id: string + status: 'pending' | 'issued' | 'spent' | 'canceled' | 'expired' | 'failed' + tempo_transaction_hash: string | null + token_address: string + updated_at: k.Generated + workspace_id: string +} + +type scoped_credit_event = { + created_at: k.Generated + details_json: string | null + event_type: + | 'created' + | 'sender_confirmed' + | 'issued' + | 'recipient_notified' + | 'spend_started' + | 'paid' + | 'canceled' + | 'expired' + | 'failed' + id: string + scoped_credit_id: string +} + type tip = { access_key_id: string | null amount: number @@ -173,6 +217,8 @@ export declare namespace DB { type provider_identity = k.Selectable type reaction_tip = k.Selectable type reaction_tip_thread = k.Selectable + type scoped_credit = k.Selectable + type scoped_credit_event = k.Selectable type tip = k.Selectable type tip_batch = k.Selectable type workspace = k.Selectable @@ -185,6 +231,8 @@ export declare namespace DB { type provider_identity = k.Insertable type reaction_tip = k.Insertable type reaction_tip_thread = k.Insertable + type scoped_credit = k.Insertable + type scoped_credit_event = k.Insertable type tip = k.Insertable type tip_batch = k.Insertable type workspace = k.Insertable @@ -198,6 +246,8 @@ export declare namespace DB { type provider_identity = k.Selectable type reaction_tip = k.Selectable type reaction_tip_thread = k.Selectable + type scoped_credit = k.Selectable + type scoped_credit_event = k.Selectable type tip = k.Selectable type tip_batch = k.Selectable type workspace = k.Selectable @@ -211,6 +261,8 @@ export declare namespace DB { type provider_identity = k.Updateable type reaction_tip = k.Updateable type reaction_tip_thread = k.Updateable + type scoped_credit = k.Updateable + type scoped_credit_event = k.Updateable type tip = k.Updateable type tip_batch = k.Updateable type workspace = k.Updateable diff --git a/src/chat.ts b/src/chat.ts index 32f7f97..5873199 100644 --- a/src/chat.ts +++ b/src/chat.ts @@ -6,6 +6,7 @@ import { getSlackBotDisplayName, getSlackCommand } from '#/lib/app.ts' import * as Emoji from '#/lib/emoji.ts' import { formatAmount, formatCurrencyAmount, formatTipAmount } from '#/lib/format.ts' import * as Nanoid from '#/lib/nanoid.ts' +import * as ScopedCredit from '#/lib/scopedCredit.ts' import * as Slack from '#/lib/slack.ts' import * as Tempo from '#/lib/tempo.ts' import * as Tip from '#/lib/tip.ts' @@ -322,6 +323,18 @@ export function getSlack() { } const actions = { + async cancel_credit(event) { + const id = z.safeParse(z.string().min(1), event.value) + if (!id.success) return + + await event.adapter.deleteMessage(event.threadId, event.messageId) + const result = await ScopedCredit.cancelScopedCredit(DB.create(env.DB), { + actorProviderUserId: event.user.userId, + id: id.data, + }) + if (!result.ok && event.thread) + await event.thread.postEphemeral(event.user, result.message, { fallbackToDM: false }) + }, async config_edit(event) { const raw = z.parse( z.object({ @@ -421,6 +434,51 @@ const actions = { async confirm_cancel(event) { await event.adapter.deleteMessage(event.threadId, event.messageId) }, + async confirm_credit(event) { + const id = z.safeParse(z.string().min(1), event.value) + if (!id.success) return + + await event.adapter.deleteMessage(event.threadId, event.messageId) + const result = await ScopedCredit.confirmScopedCredit(DB.create(env.DB), { + actorProviderUserId: event.user.userId, + id: id.data, + }) + if (!result.ok) { + if (event.thread) + await event.thread.postEphemeral(event.user, result.message, { fallbackToDM: false }) + return + } + + const scopedEvent = scopedCreditTipEvent(result.credit, event.user) + await postPrivateReply( + scopedEvent, + event.user, + `Credit issued. ${scopedEvent.channel.mentionUser(result.credit.recipient_provider_user_id)} can now spend ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} at ${result.credit.merchant_name}.`, + ) + await postPrivateReply( + scopedEvent, + { userId: result.credit.recipient_provider_user_id } as chat.Author, + { + card: chat.Card({ + children: [ + chat.CardText( + `${scopedEvent.channel.mentionUser(result.credit.sender_provider_user_id)} sent you ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} for ${result.credit.merchant_name}.`, + ), + chat.Actions([ + chat.Button({ + id: 'spend_credit', + label: 'Spend credit', + style: 'primary', + value: result.credit.id, + }), + ]), + chat.CardText('For this MVP, spending creates a fake MPP receipt.', { style: 'muted' }), + ], + }), + fallbackText: `${scopedEvent.channel.mentionUser(result.credit.sender_provider_user_id)} sent you ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} for ${result.credit.merchant_name}.`, + }, + ) + }, async confirm_tip(event) { const token = z.safeParse(z.string().min(1), event.value) if (!token.success) return @@ -498,6 +556,27 @@ const actions = { })() await postTipResult(tipEvent, ctx, result, { ...pending, threadTs: pending.providerThreadId }) }, + async spend_credit(event) { + const id = z.safeParse(z.string().min(1), event.value) + if (!id.success) return + + const result = await ScopedCredit.spendScopedCredit(DB.create(env.DB), { + actorProviderUserId: event.user.userId, + id: id.data, + }) + if (!result.ok) { + if (event.thread) + await event.thread.postEphemeral(event.user, result.message, { fallbackToDM: false }) + return + } + + await event.adapter.deleteMessage(event.threadId, event.messageId) + await postPrivateReply( + scopedCreditTipEvent(result.credit, event.user), + event.user, + `Paid ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} to ${result.credit.merchant_name}. MPP receipt: \`${result.credit.mpp_receipt_id}\``, + ) + }, } as const satisfies Record< (typeof actionNames)[number], (event: chat.ActionEvent) => Promise @@ -696,6 +775,41 @@ const handlers = { async connect(event, ctx) { await postConnectLink(event, ctx) }, + async credit(event, ctx) { + const result = await ScopedCredit.createPendingScopedCredit(ctx.db, { + idempotencyKey: `credit:${ctx.provider.id}:${'triggerId' in event && event.triggerId ? event.triggerId : Nanoid.generate()}`, + provider: ctx.provider.type, + providerChannelId: event.channel.id, + providerId: ctx.provider.id, + providerThreadId: ctx.threadTs, + senderProviderUserId: event.user.userId, + text: ctx.text, + }) + if (!result.ok) { + await postPrivateReply(event, event.user, result.message) + return + } + + await postPrivateReply(event, event.user, { + card: chat.Card({ + children: [ + chat.CardText( + `Send ${event.channel.mentionUser(result.recipientProviderUserId)} ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} that can only be spent at ${result.credit.merchant_name}?`, + ), + chat.Actions([ + chat.Button({ + id: 'confirm_credit', + label: 'Confirm credit', + style: 'primary', + value: result.credit.id, + }), + chat.Button({ id: 'cancel_credit', label: 'Cancel', value: result.credit.id }), + ]), + ], + }), + fallbackText: `Send ${event.channel.mentionUser(result.recipientProviderUserId)} ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} that can only be spent at ${result.credit.merchant_name}?`, + }) + }, async disconnect(event, ctx) { const workspace = await ctx.db .selectFrom('workspace') @@ -776,6 +890,10 @@ const handlers = { [`${getSlackCommand(env.HOST)} balance`, 'Show wallet balance'], [`${getSlackCommand(env.HOST)} config`, 'Manage workspace configuration'], [`${getSlackCommand(env.HOST)} connect`, 'Connect to Tipbot'], + [ + `${getSlackCommand(env.HOST)} credit @account 2 prospectbutcher`, + 'Send merchant-only credit', + ], [`${getSlackCommand(env.HOST)} disconnect`, 'Disconnect from Tipbot'], [`${getSlackCommand(env.HOST)} help`, 'Show help message'], [`${getSlackCommand(env.HOST)} leaderboard`, 'Show top tippers and recipients'], @@ -1419,6 +1537,7 @@ const commandNames = [ 'balance', 'config', 'connect', + 'credit', 'disconnect', 'help', 'leaderboard', @@ -1427,7 +1546,15 @@ const commandNames = [ ] as const const commandPattern = new RegExp(`^(${commandNames.join('|')})(?:\\s+([\\s\\S]*))?$`) const slackConnectExternalCommandNames = ['connect', 'disconnect', 'help', 'status'] as const -const actionNames = ['config_edit', 'connect_cancel', 'confirm_cancel', 'confirm_tip'] as const +const actionNames = [ + 'cancel_credit', + 'config_edit', + 'connect_cancel', + 'confirm_cancel', + 'confirm_credit', + 'confirm_tip', + 'spend_credit', +] as const const modalSubmitNames = ['config_edit'] as const const tokenOptions = [ { address: Tempo.addressLookup.pathUsd, label: 'PathUSD', value: 'pathUSD' }, @@ -3279,6 +3406,17 @@ async function postPrivateReply( await channel.postEphemeral(user, message, { fallbackToDM: false }) } +function scopedCreditTipEvent( + credit: DB_gen.Selectable.scoped_credit, + user: chat.Author, +): TipEvent { + return { + channel: getChat().channel(credit.provider_channel_id), + threadTs: credit.provider_thread_id ?? undefined, + user, + } +} + async function postSlackTipPreview( event: TipEvent, pending: PendingSlackTip & { amountText: string }, diff --git a/src/chat.workers.test.ts b/src/chat.workers.test.ts index 7966e7e..8443df0 100644 --- a/src/chat.workers.test.ts +++ b/src/chat.workers.test.ts @@ -3,6 +3,7 @@ import * as AccessKey from '#/lib/accessKey.ts' import * as AccountLink from '#/lib/accountLink.ts' import * as Chat from '#/chat.ts' import * as Nanoid from '#/lib/nanoid.ts' +import * as ScopedCredit from '#/lib/scopedCredit.ts' import * as Tempo from '#/lib/tempo.ts' import * as Tip from '#/lib/tip.ts' import { WebClient } from '@slack/web-api' @@ -68,6 +69,55 @@ beforeEach(async () => { }) }) +test('/tip credit creates and spends merchant-scoped credit', async () => { + const response = await postSlashCommand( + `credit <@${Constants.slack.memberUserId}> 2 prospectbutcher`, + ) + const pending = await db + .selectFrom('scoped_credit') + .selectAll() + .where('sender_provider_user_id', '=', Constants.slack.adminUserId) + .where('recipient_provider_user_id', '=', Constants.slack.memberUserId) + .executeTakeFirstOrThrow() + + expect(response.status).toBe(200) + await expectSlackMessage('that can only be spent at Prospect Butcher Co.') + expect(pending).toMatchObject({ + amount: 2_000_000, + merchant_id: 'prospectbutcher', + status: 'pending', + }) + + const issued = await ScopedCredit.confirmScopedCredit(db, { + actorProviderUserId: Constants.slack.adminUserId, + id: pending.id, + }) + if (!issued.ok) throw new Error(issued.message) + const spent = await ScopedCredit.spendScopedCredit(db, { + actorProviderUserId: Constants.slack.memberUserId, + id: pending.id, + }) + if (!spent.ok) throw new Error(spent.message) + const events = await db + .selectFrom('scoped_credit_event') + .select('event_type') + .where('scoped_credit_id', '=', pending.id) + .orderBy('created_at', 'asc') + .execute() + + expect(issued.credit.tempo_transaction_hash).toMatch(/^fake_tempo_tx_/) + expect(spent.credit.mpp_receipt_id).toMatch(/^fake_mpp_receipt_/) + expect(spent.credit.status).toBe('spent') + expect(events.map((event) => event.event_type)).toEqual([ + 'created', + 'sender_confirmed', + 'issued', + 'recipient_notified', + 'spend_started', + 'paid', + ]) +}, 20_000) // 20 seconds + describe('/tip @account', () => { test('sends tip', async () => { await connectTipAccounts() diff --git a/src/lib/scopedCredit.test.ts b/src/lib/scopedCredit.test.ts new file mode 100644 index 0000000..2e18c49 --- /dev/null +++ b/src/lib/scopedCredit.test.ts @@ -0,0 +1,22 @@ +import * as ScopedCredit from '#/lib/scopedCredit.ts' +import { expect, test } from 'vitest' + +test('parses scoped credit command text', () => { + expect(ScopedCredit.parseScopedCreditText('<@UMEMBER|member> 2 prospectbutcher')).toEqual({ + amount: 2_000_000, + merchantId: 'prospectbutcher', + recipientProviderLabel: 'member', + recipientProviderUserId: 'UMEMBER', + }) + expect(ScopedCredit.parseScopedCreditText('<@UMEMBER> $2 prospect-butcher')).toMatchObject({ + amount: 2_000_000, + merchantId: 'prospectbutcher', + recipientProviderUserId: 'UMEMBER', + }) +}) + +test('rejects invalid scoped credit command text', () => { + expect(ScopedCredit.parseScopedCreditText('hello')).toBe(null) + expect(ScopedCredit.parseScopedCreditText('<@UMEMBER> prospectbutcher')).toBe(null) + expect(ScopedCredit.parseScopedCreditText('<@UMEMBER> $0 prospectbutcher')?.amount).toBe(null) +}) diff --git a/src/lib/scopedCredit.ts b/src/lib/scopedCredit.ts new file mode 100644 index 0000000..a51d39e --- /dev/null +++ b/src/lib/scopedCredit.ts @@ -0,0 +1,428 @@ +import * as DB from '#db/client.ts' +import type { DB as Database } from '#db/types.gen.ts' +import { formatAmount, formatCurrencyAmount } from '#/lib/format.ts' +import * as Nanoid from '#/lib/nanoid.ts' +import * as Tempo from '#/lib/tempo.ts' +import * as Tip from '#/lib/tip.ts' + +export const maxScopedCreditAmount = 10_000_000 // $10 +export const scopedCreditExpiryMs = 7 * 24 * 60 * 60 * 1000 // 7 days + +export type ScopedCreditCreateResult = + | { + credit: Database.Selectable.scoped_credit + merchant: ScopedCreditMerchant + ok: true + recipientProviderUserId: string + senderProviderUserId: string + } + | { + code: + | 'invalid_amount' + | 'invalid_merchant' + | 'invalid_usage' + | 'self_credit' + | 'too_large' + | 'workspace_missing' + message: string + ok: false + } + +export type ScopedCreditMerchant = { + id: string + merchantAddress: string + mppBaseUrl: string + name: string + tokenAddress: string +} + +export const scopedCreditMerchants: Record = { + prospectbutcher: { + id: 'prospectbutcher', + merchantAddress: '0x0000000000000000000000000000000000001015', + mppBaseUrl: 'https://prospect-butcher.example/mpp', + name: 'Prospect Butcher Co.', + tokenAddress: Tempo.addressLookup.pathUsd, + }, +} as const + +export function parseScopedCreditText(value: string) { + const text = value.trim() + const match = text.match( + /^<@([A-Z0-9_]+)(?:\|([^>]+))?>\s+(\$?(?:0|[1-9]\d*)(?:\.\d+)?)\s+([a-z0-9_-]+)$/i, + ) + if (!match) return null + + const amount = Tip.parseAmount(match[3]!) + return { + amount, + merchantId: normalizeMerchantId(match[4]!), + recipientProviderLabel: match[2]?.trim(), + recipientProviderUserId: match[1]!, + } +} + +export function formatScopedCreditAmount(amount: number) { + return formatCurrencyAmount(formatAmount(amount), 'USD') +} + +export async function createPendingScopedCredit( + db: DB.Type, + input: { + idempotencyKey: string + provider: Database.Selectable.workspace['provider'] + providerChannelId: string + providerId: string + providerThreadId?: string + senderProviderUserId: string + text: string + }, +): Promise { + const parsed = parseScopedCreditText(input.text) + if (!parsed || parsed.amount === null) + return { + code: parsed ? 'invalid_amount' : 'invalid_usage', + message: 'Use `credit @account 2 prospectbutcher` to send a Prospect Butcher-only credit.', + ok: false, + } + const merchant = scopedCreditMerchants[parsed.merchantId] + if (!merchant) + return { + code: 'invalid_merchant', + message: 'Merchant not available. For this MVP, use `prospectbutcher`.', + ok: false, + } + if (parsed.amount > maxScopedCreditAmount) + return { + code: 'too_large', + message: `Scoped credits are capped at ${formatScopedCreditAmount(maxScopedCreditAmount)} for this MVP.`, + ok: false, + } + if (input.senderProviderUserId === parsed.recipientProviderUserId) + return { + code: 'self_credit', + message: 'Payment not sent. Cannot send a credit to yourself.', + ok: false, + } + + const workspace = await getOrCreateWorkspace(db, input.provider, input.providerId) + const sender = await getOrCreateMember(db, workspace, input.senderProviderUserId) + const recipient = await getOrCreateMember(db, workspace, parsed.recipientProviderUserId, { + login: parsed.recipientProviderLabel, + }) + const existing = await db + .selectFrom('scoped_credit') + .selectAll() + .where('idempotency_key', '=', input.idempotencyKey) + .executeTakeFirst() + if (existing) + return { + credit: existing, + merchant, + ok: true, + recipientProviderUserId: existing.recipient_provider_user_id, + senderProviderUserId: existing.sender_provider_user_id, + } + + const id = Nanoid.generate() + const now = new Date().toISOString() + await db + .insertInto('scoped_credit') + .values({ + amount: parsed.amount, + created_at: now, + expires_at: new Date(Date.now() + scopedCreditExpiryMs).toISOString(), + failed_at: null, + failure_reason: null, + id, + idempotency_key: input.idempotencyKey, + merchant_address: merchant.merchantAddress, + merchant_id: merchant.id, + merchant_name: merchant.name, + mpp_receipt_id: null, + provider_channel_id: input.providerChannelId, + provider_thread_id: input.providerThreadId ?? null, + recipient_member_id: recipient.id, + recipient_provider_user_id: recipient.provider_user_id, + sender_member_id: sender.id, + sender_provider_user_id: sender.provider_user_id, + status: 'pending', + tempo_transaction_hash: null, + token_address: merchant.tokenAddress, + updated_at: now, + workspace_id: workspace.id, + }) + .execute() + await insertScopedCreditEvent(db, id, 'created', { + amount: parsed.amount, + merchantId: merchant.id, + recipientProviderUserId: recipient.provider_user_id, + senderProviderUserId: sender.provider_user_id, + }) + + return { + credit: await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow(), + merchant, + ok: true, + recipientProviderUserId: recipient.provider_user_id, + senderProviderUserId: sender.provider_user_id, + } +} + +export async function confirmScopedCredit( + db: DB.Type, + input: { actorProviderUserId: string; id: string }, +) { + const credit = await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', input.id) + .executeTakeFirst() + if (!credit) + return { code: 'not_found' as const, message: 'Credit not found.', ok: false as const } + if (credit.sender_provider_user_id !== input.actorProviderUserId) + return { + code: 'wrong_actor' as const, + message: 'Only the sender can confirm this credit.', + ok: false as const, + } + if (credit.status !== 'pending') + return { + code: 'wrong_status' as const, + message: `Credit is already ${credit.status}.`, + ok: false as const, + } + + const now = new Date().toISOString() + const tempoTransactionHash = `fake_tempo_tx_${Nanoid.generate()}` + await db + .updateTable('scoped_credit') + .set({ + status: 'issued', + tempo_transaction_hash: tempoTransactionHash, + updated_at: now, + }) + .where('id', '=', credit.id) + .execute() + await insertScopedCreditEvent(db, credit.id, 'sender_confirmed') + await insertScopedCreditEvent(db, credit.id, 'issued', { + tempoPolicy: { + recipient: credit.merchant_address, + sender: 'credit_holder', + tip: 'TIP-1015', + }, + tempoTransactionHash, + }) + await insertScopedCreditEvent(db, credit.id, 'recipient_notified') + + return { + credit: await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', credit.id) + .executeTakeFirstOrThrow(), + ok: true as const, + } +} + +export async function cancelScopedCredit( + db: DB.Type, + input: { actorProviderUserId: string; id: string }, +) { + const credit = await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', input.id) + .executeTakeFirst() + if (!credit) + return { code: 'not_found' as const, message: 'Credit not found.', ok: false as const } + if (credit.sender_provider_user_id !== input.actorProviderUserId) + return { + code: 'wrong_actor' as const, + message: 'Only the sender can cancel this credit.', + ok: false as const, + } + if (credit.status !== 'pending') + return { + code: 'wrong_status' as const, + message: `Credit is already ${credit.status}.`, + ok: false as const, + } + + await db + .updateTable('scoped_credit') + .set({ status: 'canceled', updated_at: new Date().toISOString() }) + .where('id', '=', credit.id) + .execute() + await insertScopedCreditEvent(db, credit.id, 'canceled') + return { ok: true as const } +} + +export async function spendScopedCredit( + db: DB.Type, + input: { actorProviderUserId: string; id: string }, +) { + const credit = await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', input.id) + .executeTakeFirst() + if (!credit) + return { code: 'not_found' as const, message: 'Credit not found.', ok: false as const } + if (credit.recipient_provider_user_id !== input.actorProviderUserId) + return { + code: 'wrong_actor' as const, + message: 'Only the recipient can spend this credit.', + ok: false as const, + } + if (credit.status !== 'issued') + return { + code: 'wrong_status' as const, + message: `Credit is ${credit.status}, not available.`, + ok: false as const, + } + + const mppReceiptId = `fake_mpp_receipt_${Nanoid.generate()}` + await insertScopedCreditEvent(db, credit.id, 'spend_started', { + mppBaseUrl: scopedCreditMerchants[credit.merchant_id]?.mppBaseUrl, + }) + await db + .updateTable('scoped_credit') + .set({ + mpp_receipt_id: mppReceiptId, + status: 'spent', + updated_at: new Date().toISOString(), + }) + .where('id', '=', credit.id) + .execute() + await insertScopedCreditEvent(db, credit.id, 'paid', { mppReceiptId }) + return { + credit: await db + .selectFrom('scoped_credit') + .selectAll() + .where('id', '=', credit.id) + .executeTakeFirstOrThrow(), + ok: true as const, + } +} + +async function getOrCreateWorkspace( + db: DB.Type, + provider: Database.Selectable.workspace['provider'], + providerId: string, +) { + const existing = await db + .selectFrom('workspace') + .selectAll() + .where('provider', '=', provider) + .where('provider_id', '=', providerId) + .executeTakeFirst() + if (existing) return existing + + const id = Nanoid.generate() + const now = new Date().toISOString() + await db + .insertInto('workspace') + .values({ + chain_id: Tempo.chainLookup.mainnet, + created_at: now, + default_amount: 1000, + id, + provider, + provider_id: providerId, + reaction_tip_emoji: 'money_with_wings', + updated_at: now, + }) + .execute() + return await db.selectFrom('workspace').selectAll().where('id', '=', id).executeTakeFirstOrThrow() +} + +async function getOrCreateMember( + db: DB.Type, + workspace: Database.Selectable.workspace, + providerUserId: string, + options: { login?: string } = {}, +) { + const existing = await db + .selectFrom('member') + .selectAll() + .where('workspace_id', '=', workspace.id) + .where('provider_user_id', '=', providerUserId) + .executeTakeFirst() + if (existing) return existing + + const now = new Date().toISOString() + const providerIdentity = + (await db + .selectFrom('provider_identity') + .selectAll() + .where('provider', '=', workspace.provider) + .where('provider_workspace_id', '=', workspace.provider_id) + .where('provider_user_id', '=', providerUserId) + .executeTakeFirst()) ?? + (await (async () => { + const id = Nanoid.generate() + await db + .insertInto('provider_identity') + .values({ + account_id: null, + created_at: now, + display_name: options.login ?? null, + id, + metadata: null, + provider: workspace.provider, + provider_global_user_id: null, + provider_user_id: providerUserId, + provider_workspace_id: workspace.provider_id, + real_name: null, + updated_at: now, + }) + .execute() + return await db + .selectFrom('provider_identity') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow() + })()) + const id = Nanoid.generate() + await db + .insertInto('member') + .values({ + created_at: now, + id, + login: options.login ?? null, + name: null, + provider_identity_id: providerIdentity.id, + provider_user_id: providerUserId, + updated_at: now, + workspace_id: workspace.id, + }) + .execute() + return await db.selectFrom('member').selectAll().where('id', '=', id).executeTakeFirstOrThrow() +} + +async function insertScopedCreditEvent( + db: DB.Type, + scopedCreditId: string, + eventType: Database.Insertable.scoped_credit_event['event_type'], + details?: Record, +) { + await db + .insertInto('scoped_credit_event') + .values({ + created_at: new Date().toISOString(), + details_json: details ? JSON.stringify(details) : null, + event_type: eventType, + id: Nanoid.generate(), + scoped_credit_id: scopedCreditId, + }) + .execute() +} + +function normalizeMerchantId(value: string) { + return value.toLowerCase().replace(/[-_\s]+/g, '') +} From 8548dffdb4c632371aae6248161cbcb36c9a2252 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 1 Jun 2026 22:15:56 +0200 Subject: [PATCH 2/2] feat: add scoped credit receipt memo --- db/migrations/0011_scoped_credit.sql | 1 + db/schemas.gen.ts | 1 + db/types.gen.ts | 1 + src/chat.ts | 2 +- src/chat.workers.test.ts | 4 ++++ src/lib/scopedCredit.test.ts | 10 ++++++++++ src/lib/scopedCredit.ts | 19 ++++++++++++++++++- 7 files changed, 36 insertions(+), 2 deletions(-) diff --git a/db/migrations/0011_scoped_credit.sql b/db/migrations/0011_scoped_credit.sql index 49768c2..78096d2 100644 --- a/db/migrations/0011_scoped_credit.sql +++ b/db/migrations/0011_scoped_credit.sql @@ -9,6 +9,7 @@ CREATE TABLE "scoped_credit" ( "recipient_member_id" TEXT NOT NULL REFERENCES "member" ("id") ON DELETE CASCADE, "sender_provider_user_id" TEXT NOT NULL, "recipient_provider_user_id" TEXT NOT NULL, + "receipt_memo" TEXT NOT NULL DEFAULT 'Scoped credit', "merchant_id" TEXT NOT NULL, "merchant_name" TEXT NOT NULL, "merchant_address" TEXT NOT NULL, diff --git a/db/schemas.gen.ts b/db/schemas.gen.ts index 233464d..b7c5f6c 100644 --- a/db/schemas.gen.ts +++ b/db/schemas.gen.ts @@ -106,6 +106,7 @@ export const scoped_credit = z.object({ mpp_receipt_id: z.string().nullable(), provider_channel_id: z.string(), provider_thread_id: z.string().nullable(), + receipt_memo: z.string(), recipient_member_id: z.string(), recipient_provider_user_id: z.string(), sender_member_id: z.string(), diff --git a/db/types.gen.ts b/db/types.gen.ts index 7b11739..ac2eb85 100644 --- a/db/types.gen.ts +++ b/db/types.gen.ts @@ -121,6 +121,7 @@ type scoped_credit = { mpp_receipt_id: string | null provider_channel_id: string provider_thread_id: string | null + receipt_memo: k.Generated recipient_member_id: string recipient_provider_user_id: string sender_member_id: string diff --git a/src/chat.ts b/src/chat.ts index 5873199..87edc97 100644 --- a/src/chat.ts +++ b/src/chat.ts @@ -574,7 +574,7 @@ const actions = { await postPrivateReply( scopedCreditTipEvent(result.credit, event.user), event.user, - `Paid ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} to ${result.credit.merchant_name}. MPP receipt: \`${result.credit.mpp_receipt_id}\``, + `Paid ${ScopedCredit.formatScopedCreditAmount(result.credit.amount)} to ${result.credit.merchant_name}.\n${result.credit.receipt_memo}\nMPP receipt: \`${result.credit.mpp_receipt_id}\``, ) }, } as const satisfies Record< diff --git a/src/chat.workers.test.ts b/src/chat.workers.test.ts index 8443df0..f711752 100644 --- a/src/chat.workers.test.ts +++ b/src/chat.workers.test.ts @@ -85,6 +85,7 @@ test('/tip credit creates and spends merchant-scoped credit', async () => { expect(pending).toMatchObject({ amount: 2_000_000, merchant_id: 'prospectbutcher', + receipt_memo: `Scoped credit for <@${Constants.slack.memberUserId}> from <@${Constants.slack.adminUserId}>; spendable only at Prospect Butcher Co.`, status: 'pending', }) @@ -107,6 +108,9 @@ test('/tip credit creates and spends merchant-scoped credit', async () => { expect(issued.credit.tempo_transaction_hash).toMatch(/^fake_tempo_tx_/) expect(spent.credit.mpp_receipt_id).toMatch(/^fake_mpp_receipt_/) + expect(spent.credit.receipt_memo).toBe( + `Scoped credit for <@${Constants.slack.memberUserId}> from <@${Constants.slack.adminUserId}>; spendable only at Prospect Butcher Co.`, + ) expect(spent.credit.status).toBe('spent') expect(events.map((event) => event.event_type)).toEqual([ 'created', diff --git a/src/lib/scopedCredit.test.ts b/src/lib/scopedCredit.test.ts index 2e18c49..68f6a70 100644 --- a/src/lib/scopedCredit.test.ts +++ b/src/lib/scopedCredit.test.ts @@ -20,3 +20,13 @@ test('rejects invalid scoped credit command text', () => { expect(ScopedCredit.parseScopedCreditText('<@UMEMBER> prospectbutcher')).toBe(null) expect(ScopedCredit.parseScopedCreditText('<@UMEMBER> $0 prospectbutcher')?.amount).toBe(null) }) + +test('builds scoped credit receipt memo', () => { + expect( + ScopedCredit.buildScopedCreditReceiptMemo({ + merchantName: 'Prospect Butcher Co.', + recipientProviderUserId: 'UMEMBER', + senderProviderUserId: 'UADMIN', + }), + ).toBe('Scoped credit for <@UMEMBER> from <@UADMIN>; spendable only at Prospect Butcher Co.') +}) diff --git a/src/lib/scopedCredit.ts b/src/lib/scopedCredit.ts index a51d39e..a1e70ad 100644 --- a/src/lib/scopedCredit.ts +++ b/src/lib/scopedCredit.ts @@ -66,6 +66,15 @@ export function formatScopedCreditAmount(amount: number) { return formatCurrencyAmount(formatAmount(amount), 'USD') } +export function buildScopedCreditReceiptMemo(input: { + merchantName: string + recipientProviderUserId: string + senderProviderUserId: string +}) { + const merchantName = input.merchantName.replace(/\.+$/, '') + return `Scoped credit for <@${input.recipientProviderUserId}> from <@${input.senderProviderUserId}>; spendable only at ${merchantName}.` +} + export async function createPendingScopedCredit( db: DB.Type, input: { @@ -144,6 +153,11 @@ export async function createPendingScopedCredit( provider_thread_id: input.providerThreadId ?? null, recipient_member_id: recipient.id, recipient_provider_user_id: recipient.provider_user_id, + receipt_memo: buildScopedCreditReceiptMemo({ + merchantName: merchant.name, + recipientProviderUserId: recipient.provider_user_id, + senderProviderUserId: sender.provider_user_id, + }), sender_member_id: sender.id, sender_provider_user_id: sender.provider_user_id, status: 'pending', @@ -299,7 +313,10 @@ export async function spendScopedCredit( }) .where('id', '=', credit.id) .execute() - await insertScopedCreditEvent(db, credit.id, 'paid', { mppReceiptId }) + await insertScopedCreditEvent(db, credit.id, 'paid', { + mppReceiptId, + receiptMemo: credit.receipt_memo, + }) return { credit: await db .selectFrom('scoped_credit')