From 991554cd52ef274cee2d7208be217d0476a4cf5c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 24 Oct 2026 15:15:47 +0700 Subject: [PATCH] feat(db): add shared tagged payment-proof envelope Add dormant REFUND_V1 and PAYMENT_OPERATION_V1 manifest branches with exact ownership and immutable provenance checks. Preserve REFUND application gates, historical migrations and all financial assertions. Verified 1014 PostgreSQL integration cases and 50 unit tests; six historical F01 financial failures remain unchanged. Fresh independent review approved P-A only; no payment application or financial scope is activated. --- .../0012_shared_payment_proof_envelope.sql | 108 +++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/database/schema.ts | 7 +- .../capture-accounting-migration.spec.ts | 14 +- ...l-application-legacy-compatibility.spec.ts | 8 +- .../financial-application-migration.spec.ts | 12 +- ...integrated-accounting-verification.spec.ts | 2 +- .../payment-operation-migration.spec.ts | 12 +- .../provider-observation-migration.spec.ts | 11 +- .../receipt-admission-migration.spec.ts | 2 +- .../receipt-delivery-migration.spec.ts | 2 +- .../settlement-accounting-integration.spec.ts | 2 +- ...d-payment-proof-envelope-migration.spec.ts | 88 +++++++++ .../shared-payment-proof-envelope.spec.ts | 168 ++++++++++++++++++ .../trusted-evidence-migration.spec.ts | 4 +- .../support/provider-observation-harness.ts | 2 +- .../test/support/trusted-receipt-harness.ts | 4 +- docs/database-design.md | 2 + 18 files changed, 421 insertions(+), 34 deletions(-) create mode 100644 apps/api/drizzle/0012_shared_payment_proof_envelope.sql create mode 100644 apps/api/test/integration/shared-payment-proof-envelope-migration.spec.ts create mode 100644 apps/api/test/integration/shared-payment-proof-envelope.spec.ts diff --git a/apps/api/drizzle/0012_shared_payment_proof_envelope.sql b/apps/api/drizzle/0012_shared_payment_proof_envelope.sql new file mode 100644 index 0000000..788f2c0 --- /dev/null +++ b/apps/api/drizzle/0012_shared_payment_proof_envelope.sql @@ -0,0 +1,108 @@ +-- P-A: immutable tagged storage only. No payment application contract, permit +-- issuer, source discharge, runtime grant, enrollment or financial effect. +SET LOCAL lock_timeout='10s'; +SET LOCAL search_path=pg_catalog,pg_temp; + +-- Fail closed if the installed legacy owner key is not the expected exact FK. +DO $$ DECLARE c record; n integer:=0; BEGIN + IF EXISTS(SELECT 1 FROM public.financial_application_manifests m JOIN public.financial_application_roots r ON r.id=m.root_id WHERE r.family<>'REFUND') THEN + RAISE EXCEPTION 'Expected only installed REFUND manifest interpretation'; END IF; + FOR c IN SELECT conname FROM pg_constraint WHERE conrelid='public.financial_application_manifests'::regclass + AND contype='f' AND confrelid='public.webhook_financial_evidence'::regclass AND convalidated AND NOT condeferrable + AND confmatchtype='s' AND confupdtype='a' AND confdeltype='a' + AND conkey=ARRAY(SELECT attnum FROM unnest(ARRAY['inbox_id','proof_revision','payment_id','merchant_id','currency']) WITH ORDINALITY k(name,n) + JOIN pg_attribute ON attrelid='public.financial_application_manifests'::regclass AND attname=k.name ORDER BY k.n) + AND confkey=ARRAY(SELECT attnum FROM unnest(ARRAY['inbox_id','proof_revision','payment_id','merchant_id','currency']) WITH ORDINALITY k(name,n) + JOIN pg_attribute ON attrelid='public.webhook_financial_evidence'::regclass AND attname=k.name ORDER BY k.n) + LOOP + EXECUTE format('ALTER TABLE public.financial_application_manifests DROP CONSTRAINT %I',c.conname); n:=n+1; + END LOOP; + IF n<>1 THEN RAISE EXCEPTION 'Expected exact installed REFUND manifest owner FK'; END IF; +END $$; +ALTER TABLE public.financial_application_manifests + ADD COLUMN proof_kind text NOT NULL DEFAULT 'REFUND_V1', + ADD COLUMN payment_operation_proof_id uuid, + ADD COLUMN legacy_proof_inbox_id uuid GENERATED ALWAYS AS (CASE WHEN proof_kind='REFUND_V1' THEN inbox_id END) STORED, + ADD COLUMN payment_proof_inbox_id uuid GENERATED ALWAYS AS (CASE WHEN proof_kind='PAYMENT_OPERATION_V1' THEN inbox_id END) STORED, + ADD CONSTRAINT application_manifest_proof_branch CHECK(((proof_kind='REFUND_V1' AND payment_operation_proof_id IS NULL) + OR (proof_kind='PAYMENT_OPERATION_V1' AND payment_operation_proof_id IS NOT NULL)) IS TRUE), + ADD CONSTRAINT application_manifest_refund_proof FOREIGN KEY(legacy_proof_inbox_id,proof_revision,payment_id,merchant_id,currency) + REFERENCES public.webhook_financial_evidence(inbox_id,proof_revision,payment_id,merchant_id,currency), + ADD CONSTRAINT application_manifest_payment_proof FOREIGN KEY(payment_proof_inbox_id,payment_operation_proof_id,payment_id,merchant_id,currency) + REFERENCES public.payment_operation_evidence(inbox_id,id,payment_id,merchant_id,currency); +--> statement-breakpoint + +-- Interpret the immutable B1 witness, not mutable current capture eligibility. +-- This checker neither admits sources nor certifies a complete application. +CREATE FUNCTION public.check_payment_proof_envelope() RETURNS trigger +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE r public.financial_application_roots%ROWTYPE; e public.payment_operation_evidence%ROWTYPE; + a public.provider_command_authorities%ROWTYPE; auth public.provider_command_authorities%ROWTYPE; + s public.provider_observation_sources%ROWTYPE; receipt public.provider_financial_receipts%ROWTYPE; checked jsonb; baseline jsonb; +BEGIN + -- BEFORE triggers cannot read the new STORED generated linkage values. + SELECT * INTO STRICT r FROM public.financial_application_roots WHERE id=NEW.root_id; + IF NEW.proof_kind='REFUND_V1' THEN + IF r.family<>'REFUND' THEN RAISE EXCEPTION 'REFUND envelope requires REFUND root'; END IF; + RETURN NEW; + END IF; + IF NEW.proof_kind IS DISTINCT FROM 'PAYMENT_OPERATION_V1' THEN RETURN NEW; END IF; + SELECT * INTO STRICT e FROM public.payment_operation_evidence WHERE id=NEW.payment_operation_proof_id AND inbox_id=NEW.inbox_id; + SELECT * INTO STRICT a FROM public.provider_command_authorities WHERE outbox_id=e.outbox_id; + IF NEW.proof_revision IS DISTINCT FROM 1 OR e.proof_version IS DISTINCT FROM 1 OR e.validator_version IS DISTINCT FROM 'PAYMENT_OPERATION_V1' + OR (r.family,r.phase,r.operation_id,r.attempt_id,r.accepted_outbox_id,r.payment_id,r.merchant_id,r.currency,r.provider,r.provider_account,r.policy,r.acceptance_epoch) + IS DISTINCT FROM (a.operation,'TERMINAL',e.attempt_id,e.attempt_id,e.outbox_id,e.payment_id,e.merchant_id,e.currency,e.provider,e.provider_account,e.accounting_policy,e.acceptance_epoch) + OR (NEW.payment_id,NEW.merchant_id,NEW.currency,NEW.policy,NEW.epoch,NEW.proof_operation) + IS DISTINCT FROM (e.payment_id,e.merchant_id,e.currency,e.accounting_policy,e.acceptance_epoch,e.operation) + OR (a.operation_id,a.payment_id,a.merchant_id,a.currency,a.provider,a.provider_account,a.provider_mode,a.accounting_policy,a.acceptance_epoch) + IS DISTINCT FROM (e.attempt_id,e.payment_id,e.merchant_id,e.currency,e.provider,e.provider_account,e.provider_mode,e.accounting_policy,e.acceptance_epoch) + OR (a.operation,e.operation,e.outcome) NOT IN (('AUTHORIZE','payment.authorized','AUTHORIZE_SUCCEEDED'), + ('CAPTURE','payment.capture_succeeded','CAPTURE_SUCCEEDED'),('VOID','payment.cancelled','VOID_SUCCEEDED')) THEN + RAISE EXCEPTION 'Payment envelope requires exact canonical root and typed proof'; END IF; + IF e.fingerprint IS DISTINCT FROM sha256(convert_to(e.facts::text,'UTF8')) + OR (e.operation,e.outcome,e.provider,e.provider_account,e.provider_mode,e.attempt_id,e.payment_id,e.merchant_id,e.currency,e.outbox_id,e.execution_id, + e.payment_intent_id,e.charge_id,e.capture_principal,e.authorized_principal,e.accounting_policy,e.acceptance_epoch) + IS DISTINCT FROM (e.facts->>'operation',e.facts->>'outcome',e.facts->>'provider',e.facts->>'provider_account',e.facts->>'provider_mode', + (e.facts->>'attempt_id')::uuid,(e.facts->>'payment_id')::uuid,(e.facts->>'merchant_id')::uuid,e.facts->>'currency',(e.facts->>'outbox_id')::uuid,(e.facts#>>'{source,execution_id}')::uuid, + e.facts->>'payment_intent_id',e.facts->>'charge_id',(e.facts->>'capture_principal')::bigint,(e.facts->>'authorized_principal')::bigint,e.facts->>'accounting_policy',(e.facts->>'acceptance_epoch')::bigint) + OR e.facts->>'validator' IS DISTINCT FROM 'PAYMENT_OPERATION_V1' + OR e.facts->>'interpretation' IS DISTINCT FROM (CASE WHEN a.operation='CAPTURE' THEN 'STRIPE_FIRST_CAPTURE_V1' ELSE 'STRIPE_PAYMENT_WORKFLOW_V1' END) + OR e.facts->>'command_key' IS DISTINCT FROM a.command_key + OR e.facts->>'command_hash' IS DISTINCT FROM encode(sha256(convert_to(a.command_payload::text,'UTF8')),'hex') THEN + RAISE EXCEPTION 'Payment envelope sealed interpretation mismatch'; END IF; + SELECT * INTO STRICT s FROM public.provider_observation_sources WHERE id=e.source_id AND outbox_id=e.outbox_id AND execution_id=e.execution_id; + PERFORM 1 FROM public.provider_command_executions WHERE id=e.execution_id AND outbox_id=e.outbox_id; + IF NOT FOUND THEN RAISE EXCEPTION 'Payment envelope original execution mismatch'; END IF; + checked:=public.checked_payment_command_source(s.id); + IF e.facts->'source' IS DISTINCT FROM checked THEN RAISE EXCEPTION 'Payment envelope original source mismatch'; END IF; + IF a.operation='AUTHORIZE' THEN + IF e.authorization_source_id IS NOT NULL OR e.facts->'authorization' IS DISTINCT FROM 'null'::jsonb THEN + RAISE EXCEPTION 'Authorization envelope cannot adopt a predecessor source'; END IF; + ELSE + baseline:=public.checked_payment_command_source(e.authorization_source_id); + SELECT c.* INTO STRICT auth FROM public.provider_command_authorities c JOIN public.provider_observation_sources v ON v.outbox_id=c.outbox_id WHERE v.id=e.authorization_source_id; + IF e.facts->'authorization' IS DISTINCT FROM baseline OR auth.operation IS DISTINCT FROM 'AUTHORIZE' + OR (auth.payment_id,auth.merchant_id,auth.currency,auth.provider,auth.provider_account,auth.provider_mode) + IS DISTINCT FROM (e.payment_id,e.merchant_id,e.currency,e.provider,e.provider_account,e.provider_mode) THEN + RAISE EXCEPTION 'Payment envelope original authorization mismatch'; END IF; + END IF; + SELECT * INTO STRICT receipt FROM public.provider_financial_receipts WHERE inbox_id=e.inbox_id; + IF (receipt.provider,receipt.provider_account,encode(sha256(receipt.raw_body),'hex'),receipt.verification_version,receipt.normalization_version) + IS DISTINCT FROM (e.provider,e.provider_account,e.facts->>'receipt_hash',e.facts->>'verifier',(e.facts->>'normalization_version')::integer) THEN + RAISE EXCEPTION 'Payment envelope original signed receipt mismatch'; END IF; + RETURN NEW; +END $$; +CREATE TRIGGER application_payment_proof BEFORE INSERT ON public.financial_application_manifests + FOR EACH ROW EXECUTE FUNCTION public.check_payment_proof_envelope(); + +DO $$ DECLARE owner_name text:='fintech_proof_envelope_'||md5(current_database()); BEGIN + EXECUTE format('CREATE ROLE %I NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS',owner_name); + EXECUTE format('GRANT USAGE,CREATE ON SCHEMA public TO %I',owner_name); + EXECUTE format('GRANT SELECT ON public.financial_application_roots,public.payment_operation_evidence,public.provider_command_authorities, + public.provider_command_executions,public.provider_observation_sources,public.provider_financial_receipts TO %I',owner_name); + EXECUTE format('GRANT EXECUTE ON FUNCTION public.checked_payment_command_source(uuid),public.provider_form(bytea),public.provider_form_component(text), + public.payment_json(bytea),public.check_payment_json(json),public.payment_minor(jsonb,text) TO %I',owner_name); + REVOKE ALL ON FUNCTION public.check_payment_proof_envelope() FROM PUBLIC; + EXECUTE format('ALTER FUNCTION public.check_payment_proof_envelope() OWNER TO %I',owner_name); + EXECUTE format('REVOKE CREATE ON SCHEMA public FROM %I',owner_name); +END $$; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 42f68ed..d19dd3b 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1792540800000, "tag": "0011_payment_operation_evidence", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1792627200000, + "tag": "0012_shared_payment_proof_envelope", + "breakpoints": true } ] } diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 6704418..0acc152 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -528,12 +528,17 @@ export const financialApplicationManifests = pgTable('financial_application_mani id: uuid('id').primaryKey().defaultRandom(), rootId: uuid('root_id').notNull().references(() => financialApplicationRoots.id), transactionId: xid8('transaction_id').notNull(), backendPid: integer('backend_pid').notNull(), inboxId: uuid('inbox_id').notNull(), proofRevision: integer('proof_revision').notNull(), proofOperation: text('proof_operation').notNull(), claimGeneration: integer('claim_generation').notNull(), + proofKind: text('proof_kind').notNull().default('REFUND_V1'), paymentOperationProofId: uuid('payment_operation_proof_id'), + legacyProofInboxId: uuid('legacy_proof_inbox_id').generatedAlwaysAs(sql`case when proof_kind='REFUND_V1' then inbox_id end`), + paymentProofInboxId: uuid('payment_proof_inbox_id').generatedAlwaysAs(sql`case when proof_kind='PAYMENT_OPERATION_V1' then inbox_id end`), merchantId: uuid('merchant_id').notNull(), currency: text('currency').notNull(), paymentId: uuid('payment_id').notNull(), policy: text('policy').notNull(), epoch: allocationMoney('epoch').notNull(), scopeRevision: allocationMoney('scope_revision').notNull(), intakeRevision: allocationMoney('intake_revision').notNull(), contractVersion: integer('contract_version').notNull(), result: text('result').notNull(), sourceManifest: jsonb('source_manifest').notNull(), effectManifest: jsonb('effect_manifest').notNull(), createdAt: createdAt(), }, t => [uniqueIndex('application_manifest_transaction').on(t.transactionId,t.backendPid), uniqueIndex('application_manifest_root').on(t.id,t.rootId), foreignKey({ columns: [t.rootId,t.merchantId,t.currency], foreignColumns: [financialApplicationRoots.id,financialApplicationRoots.merchantId,financialApplicationRoots.currency] }), - foreignKey({ columns: [t.inboxId,t.proofRevision,t.paymentId,t.merchantId,t.currency], foreignColumns: [webhookFinancialEvidence.inboxId,webhookFinancialEvidence.proofRevision,webhookFinancialEvidence.paymentId,webhookFinancialEvidence.merchantId,webhookFinancialEvidence.currency] })]); + check('application_manifest_proof_branch',sql`((${t.proofKind}='REFUND_V1' and ${t.paymentOperationProofId} is null) or (${t.proofKind}='PAYMENT_OPERATION_V1' and ${t.paymentOperationProofId} is not null)) is true`), + foreignKey({ name:'application_manifest_refund_proof',columns: [t.legacyProofInboxId,t.proofRevision,t.paymentId,t.merchantId,t.currency], foreignColumns: [webhookFinancialEvidence.inboxId,webhookFinancialEvidence.proofRevision,webhookFinancialEvidence.paymentId,webhookFinancialEvidence.merchantId,webhookFinancialEvidence.currency] }), + foreignKey({ name:'application_manifest_payment_proof',columns: [t.paymentProofInboxId,t.paymentOperationProofId,t.paymentId,t.merchantId,t.currency],foreignColumns:[paymentOperationEvidence.inboxId,paymentOperationEvidence.id,paymentOperationEvidence.paymentId,paymentOperationEvidence.merchantId,paymentOperationEvidence.currency] })]); export const financialApplicationPermits = pgTable('financial_application_permits', { transactionId: xid8('transaction_id').notNull(), backendPid: integer('backend_pid').notNull(), manifestId: uuid('manifest_id').notNull().unique().references(() => financialApplicationManifests.id), databasePrincipal: customType<{ data: string }>({ dataType: () => 'name' })('database_principal').notNull().default(sql`session_user`), diff --git a/apps/api/test/integration/capture-accounting-migration.spec.ts b/apps/api/test/integration/capture-accounting-migration.spec.ts index bad65d5..cc4de04 100644 --- a/apps/api/test/integration/capture-accounting-migration.spec.ts +++ b/apps/api/test/integration/capture-accounting-migration.spec.ts @@ -51,8 +51,8 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = return target; } it('installs 0000–0009 on an empty database; repeated migrator execution is a no-op', async () => { - const database = await isolated(); await apply(database,folder); - const before = await fingerprint(database); await apply(database,folder); + const database = await isolated(); await apply(database,historicalFolder(12)); + const before = await fingerprint(database); await apply(database,historicalFolder(12)); expect(await fingerprint(database)).toEqual(before); const [row] = await database.sql`select (select count(*)::integer from drizzle.__drizzle_migrations) as migrations, (select count(*)::integer from capture_accounting_lots) as lots,(select count(*)::integer from accounting_exceptions) as exceptions`; @@ -66,7 +66,7 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = const database = await isolated(); await apply(database,historicalFolder(5)); const f = await captureFixture(database); await database.sql`insert into capture_accounting_scopes (merchant_id,currency) values (${f.merchantId},'USD')`; - const before = await fingerprint(database); await apply(database,folder); await apply(database,folder); + const before = await fingerprint(database); await apply(database,historicalFolder(12)); await apply(database,historicalFolder(12)); expect(await fingerprint(database)).toEqual(before); const labels = await database.sql<{enumlabel:string}[]>`select enumlabel from pg_enum where enumtypid='public.inbox_status'::regtype and enumlabel='ACCOUNTING_EXCEPTION'`; expect(labels).toEqual([{enumlabel:'ACCOUNTING_EXCEPTION'}]); @@ -117,7 +117,7 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = ]})); const before = await fingerprint(database); const functionsBefore = await database.sql<{proname:string;definition:string}[]>`select proname,pg_get_functiondef(oid) as definition from pg_proc where pronamespace='public'::regnamespace order by proname`; - await apply(database,folder); expect(await fingerprint(database)).toEqual(before); + await apply(database,historicalFolder(12)); expect(await fingerprint(database)).toEqual(before); const [orphanException] = await database.sql`select status,observed_conflict->>'journal_id' as journal from accounting_exceptions where evidence_key=${`legacy-journal:${orphan}`}`; expect(orphanException).toMatchObject({status:'OPEN',journal:orphan}); for (const c of cases) { @@ -139,7 +139,7 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = const [allocations] = await database.sql`select (select count(*)::integer from refund_capture_allocations) as refunds,(select count(*)::integer from dispute_capture_allocations) as disputes`; expect(allocations).toMatchObject({refunds:0,disputes:0}); const [countsBefore] = await database.sql`select count(*)::integer as exceptions from accounting_exceptions`; - await apply(database,folder); expect(await fingerprint(database)).toEqual(before); + await apply(database,historicalFolder(12)); expect(await fingerprint(database)).toEqual(before); const [countsAfter] = await database.sql`select count(*)::integer as exceptions from accounting_exceptions`; expect(countsAfter).toEqual(countsBefore); },30000); it('classifies NULL-owned legacy pending entries for review while backfilling a valid capture', async () => { @@ -158,7 +158,7 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = {accountCode:'PLATFORM_FEE_REVENUE',merchantId:null,credit:300}, ]}); }); - const before = await fingerprint(database); await apply(database,folder); + const before = await fingerprint(database); await apply(database,historicalFolder(12)); expect(await fingerprint(database)).toEqual(before); const [invalid] = await database.sql`select original_valid,classification from capture_accounting_legacy_inventory where capture_attempt_id=${attemptId}`; expect(invalid).toEqual({original_valid:false,classification:'REVIEW_REQUIRED'}); @@ -181,7 +181,7 @@ describeMigration('B1 migration freshness and conservative legacy upgrade', () = const before = await fingerprint(database); // Drizzle wraps the original PostgreSQL exception; assert its cause too. let rejection: unknown; - try { await apply(database,folder); } catch (error: unknown) { rejection=error; } + try { await apply(database,historicalFolder(12)); } catch (error: unknown) { rejection=error; } expect(rejection).toBeDefined(); expect((rejection as {cause:Error}).cause.message).toMatch(/Unscoped legacy CAPTURE/i); expect(await fingerprint(database)).toEqual(before); diff --git a/apps/api/test/integration/financial-application-legacy-compatibility.spec.ts b/apps/api/test/integration/financial-application-legacy-compatibility.spec.ts index 323001c..ef6772c 100644 --- a/apps/api/test/integration/financial-application-legacy-compatibility.spec.ts +++ b/apps/api/test/integration/financial-application-legacy-compatibility.spec.ts @@ -98,15 +98,15 @@ describeDb('R2-A.1 same legacy writers before and after the additive0009 upgrade if (phase === '0009') baseline.set(name, result); else { expect(baseline.has(name)).toBe(true); expect(result).toEqual(baseline.get(name)); } } - describe.each(['0009', '0010', '0011'])('schema %s', phase => { + describe.each(['0009', '0010', '0011', '0012'])('schema %s', phase => { beforeAll(async () => { if (phase !== '0009') { let source = folder; - if (phase === '0010') { + if (phase === '0010' || phase === '0011') { source = resolve(__dirname, `../../../../.tmp/r2b1-legacy-through10-${randomUUID()}`); mkdirSync(resolve(source, 'meta'), { recursive: true }); const journal = JSON.parse(readFileSync(resolve(folder, 'meta/_journal.json'), 'utf8')) as { entries: { idx: number; tag: string }[] }; - journal.entries = journal.entries.filter(e => e.idx < 11); + journal.entries = journal.entries.filter(e => e.idx < (phase === '0010' ? 11 : 12)); writeFileSync(resolve(source, 'meta/_journal.json'), JSON.stringify(journal)); for (const e of journal.entries) copyFileSync(resolve(folder, e.tag + '.sql'), resolve(source, e.tag + '.sql')); } @@ -115,7 +115,7 @@ describeDb('R2-A.1 same legacy writers before and after the additive0009 upgrade try { await migrate(drizzle(sql), { migrationsFolder: source }); } finally { await sql.end(); } expect(await fingerprint()).toEqual(before); } - expect((await h.db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(phase === '0009' ? 10 : phase === '0010' ? 11 : 12); + expect((await h.db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(phase === '0009' ? 10 : phase === '0010' ? 11 : phase === '0011' ? 12 : 13); }, 30000); afterAll(async () => { expect((await h.db.sql`select count(*)::int n from capture_accounting_scopes where status='ACTIVE'`)[0].n).toBe(0); diff --git a/apps/api/test/integration/financial-application-migration.spec.ts b/apps/api/test/integration/financial-application-migration.spec.ts index 72d37de..3838e88 100644 --- a/apps/api/test/integration/financial-application-migration.spec.ts +++ b/apps/api/test/integration/financial-application-migration.spec.ts @@ -11,11 +11,11 @@ import { publishCommandObservation, signedEvidence } from '../support/trusted-ev const describeDb = process.env.RUN_DB_TESTS === '1' && process.env.RUN_MIGRATION_TESTS === '1' ? describe : describe.skip; describeDb('R2-A additive migration compatibility', () => { const folder = resolve(__dirname, '../../drizzle'), harnesses: Awaited>[] = []; - function staged(failure = false) { + function staged(failure = false, count = 10) { const target = resolve(__dirname, `../../../../.tmp/r2a-migrations-${randomUUID()}`); mkdirSync(resolve(target, 'meta'), { recursive: true }); const journal = JSON.parse(readFileSync(resolve(folder, 'meta/_journal.json'), 'utf8')) as { entries: { idx: number; tag: string }[] }; - journal.entries = journal.entries.filter(e => e.idx < (failure ? 11 : 10)); + journal.entries = journal.entries.filter(e => e.idx < (failure ? 11 : count)); writeFileSync(resolve(target, 'meta/_journal.json'), JSON.stringify(journal)); for (const e of journal.entries) copyFileSync(resolve(folder, e.tag + '.sql'), resolve(target, e.tag + '.sql')); if (failure) { @@ -24,13 +24,13 @@ describeDb('R2-A additive migration compatibility', () => { } return target; } - async function setup(source = folder) { + async function setup(source = staged(false,12)) { const h = await providerObservationHarness(source); harnesses.push(h); const url = new URL(process.env.MIGRATION_ADMIN_URL!); url.pathname = '/' + String((await h.db.sql`select current_database() n`)[0].n); - async function apply(from = folder) { const sql = postgres(url.toString(), { max: 1 }); try { await migrate(drizzle(sql), { migrationsFolder: from }); } finally { await sql.end(); } } + async function apply(from = staged(false,12)) { const sql = postgres(url.toString(), { max: 1 }); try { await migrate(drizzle(sql), { migrationsFolder: from }); } finally { await sql.end(); } } return { h, url, apply }; } - afterAll(async () => { for (const h of harnesses) await h.close(); }); + afterAll(async () => { for (const h of harnesses) await h.close(12); }); async function fingerprint(sql: Sql) { const tables = ['ledger_transactions','ledger_entries','capture_accounting_lots','refund_capture_allocations','provider_financial_observations', 'provider_financial_receipts','webhook_financial_evidence','provider_command_authorities','provider_command_executions','provider_observation_sources','provider_observation_obligations']; @@ -93,7 +93,7 @@ describeDb('R2-A additive migration compatibility', () => { const sql = postgres(url.toString(), { max: 1 }); let pending: Promise | undefined; try { await Promise.race([started, first]); const waiter = Number((await sql`select pg_backend_pid() p`)[0].p); expect(waiter).not.toBe(holder); - pending = migrate(drizzle(sql), { migrationsFolder: folder }); let observed = false; + pending = migrate(drizzle(sql), { migrationsFolder: staged(false,12) }); let observed = false; for (let n = 0; n < 150; n++) { if ((await h.db.sql`select ${holder}=any(pg_blocking_pids(${waiter})) b`)[0].b) { observed = true; break; } await delay(20); } expect(observed).toBe(true); expect((await h.db.sql`select to_regclass('public.financial_applications') r`)[0].r).toBeNull(); release(); await Promise.all([first, pending]); await apply(); diff --git a/apps/api/test/integration/integrated-accounting-verification.spec.ts b/apps/api/test/integration/integrated-accounting-verification.spec.ts index 101d859..3714e69 100644 --- a/apps/api/test/integration/integrated-accounting-verification.spec.ts +++ b/apps/api/test/integration/integrated-accounting-verification.spec.ts @@ -37,7 +37,7 @@ describeDatabase('B3.1 integrated financial evidence (test admission, not runtim }); afterAll(async()=>{try { expect((await db.sql`select count(*)::integer as count from public.capture_accounting_scopes where status='ACTIVE'`)[0].count).toBe(0); - expect((await db.sql`select count(*)::integer as count from drizzle.__drizzle_migrations`)[0].count).toBe(12); + expect((await db.sql`select count(*)::integer as count from drizzle.__drizzle_migrations`)[0].count).toBe(13); } finally {await db.onApplicationShutdown();}}); const actor=(f:Scope)=>({id:randomUUID(),type:'API_KEY' as const,role:'MERCHANT_ADMIN' as const,merchantId:f.merchantId}); async function merchant() { diff --git a/apps/api/test/integration/payment-operation-migration.spec.ts b/apps/api/test/integration/payment-operation-migration.spec.ts index db737a0..416aade 100644 --- a/apps/api/test/integration/payment-operation-migration.spec.ts +++ b/apps/api/test/integration/payment-operation-migration.spec.ts @@ -11,19 +11,19 @@ import { publishCommandObservation, signedEvidence } from '../support/trusted-ev const describeDb=process.env.RUN_DB_TESTS==='1'&&process.env.RUN_MIGRATION_TESTS==='1'?describe:describe.skip; describeDb('R2-B1 forward migration and legacy compatibility',()=>{ const folder=resolve(__dirname,'../../drizzle'),all:Awaited>[]=[]; - function staged(failure=false){ + function staged(failure=false, count=11){ const target=resolve(__dirname,`../../../../.tmp/r2b1-migration-${randomUUID()}`);mkdirSync(resolve(target,'meta'),{recursive:true}); const journal=JSON.parse(readFileSync(resolve(folder,'meta/_journal.json'),'utf8')) as {entries:{idx:number;tag:string}[]}; - journal.entries=journal.entries.filter(e=>e.idx<(failure?12:11));writeFileSync(resolve(target,'meta/_journal.json'),JSON.stringify(journal)); + journal.entries=journal.entries.filter(e=>e.idx<(failure?12:count));writeFileSync(resolve(target,'meta/_journal.json'),JSON.stringify(journal)); for(const e of journal.entries)copyFileSync(resolve(folder,e.tag+'.sql'),resolve(target,e.tag+'.sql')); if(failure){const p=resolve(target,'0011_payment_operation_evidence.sql');writeFileSync(p,readFileSync(p,'utf8')+"\n--> statement-breakpoint\nDO $$ BEGIN RAISE EXCEPTION 'planned B1 rollback'; END $$;\n");} return target; } - async function setup(source=folder){const h=await providerObservationHarness(source);all.push(h); + async function setup(source=staged(false,12)){const h=await providerObservationHarness(source);all.push(h); const url=new URL(process.env.MIGRATION_ADMIN_URL!);url.pathname='/'+String((await h.db.sql`select current_database() n`)[0].n); - const apply=async(from=folder)=>{const sql=postgres(url.toString(),{max:1});try{await migrate(drizzle(sql),{migrationsFolder:from});}finally{await sql.end();}}; + const apply=async(from=staged(false,12))=>{const sql=postgres(url.toString(),{max:1});try{await migrate(drizzle(sql),{migrationsFolder:from});}finally{await sql.end();}}; return{h,url,apply};} - afterAll(async()=>{for(const h of all)await h.close();}); + afterAll(async()=>{for(const h of all)await h.close(12);}); async function fingerprint(sql:Sql){const out:Record={}; for(const table of ['ledger_transactions','ledger_entries','capture_accounting_lots','refund_capture_allocations','provider_financial_receipts','webhook_financial_evidence', 'receipt_admissions','receipt_admission_obligations','provider_command_authorities','provider_command_executions','provider_observation_sources','provider_observation_obligations']){ @@ -63,7 +63,7 @@ describeDb('R2-B1 forward migration and legacy compatibility',()=>{ const gate=new Promise(r=>{release=r;}),started=new Promise(r=>{ready=r;}); const first=h.db.transaction(async tx=>{holder=Number((await tx`select pg_backend_pid() p`)[0].p);await tx`lock table receipt_admission_obligations in row exclusive mode`;ready();await gate;}); const sql=postgres(url.toString(),{max:1});let pending:Promise|undefined; - try{await Promise.race([started,first]);const waiter=Number((await sql`select pg_backend_pid() p`)[0].p);pending=migrate(drizzle(sql),{migrationsFolder:folder});let observed=false; + try{await Promise.race([started,first]);const waiter=Number((await sql`select pg_backend_pid() p`)[0].p);pending=migrate(drizzle(sql),{migrationsFolder:staged(false,12)});let observed=false; for(let n=0;n<150;n++){if((await h.db.sql`select ${holder}=any(pg_blocking_pids(${waiter})) b`)[0].b){observed=true;break;}await delay(20);}expect(observed).toBe(true); expect((await h.db.sql`select to_regclass('public.payment_operation_evidence') relation`)[0].relation).toBeNull();release();await Promise.all([first,pending]);await apply(); }finally{release();await Promise.allSettled([first,...(pending?[pending]:[])]);await sql.end();} diff --git a/apps/api/test/integration/provider-observation-migration.spec.ts b/apps/api/test/integration/provider-observation-migration.spec.ts index 3fe8586..adcb14a 100644 --- a/apps/api/test/integration/provider-observation-migration.spec.ts +++ b/apps/api/test/integration/provider-observation-migration.spec.ts @@ -11,6 +11,15 @@ import { publishCommandObservation, trustedEvidenceFixture } from '../support/tr const describeDb = process.env.RUN_DB_TESTS === '1' && process.env.RUN_MIGRATION_TESTS === '1' ? describe : describe.skip; describeDb('D02-A fresh install and conservative 0008 upgrade', () => { const databases: DatabaseService[] = [], folder = resolve(__dirname, '../../drizzle'); + function historical() { + const target = resolve(__dirname, `../../../../.tmp/d02-checkpoint-${randomUUID()}`); + mkdirSync(resolve(target, 'meta'), { recursive: true }); + const journal = JSON.parse(readFileSync(resolve(folder, 'meta/_journal.json'), 'utf8')) as { entries: { idx: number; tag: string }[] }; + journal.entries = journal.entries.filter((e: { idx: number }) => e.idx < 12); + writeFileSync(resolve(target, 'meta/_journal.json'), JSON.stringify(journal)); + for (const e of journal.entries) copyFileSync(resolve(folder, e.tag + '.sql'), resolve(target, e.tag + '.sql')); + return target; + } async function isolated() { const url = new URL(process.env.MIGRATION_ADMIN_URL!); if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Disposable local cluster required'); @@ -18,7 +27,7 @@ describeDb('D02-A fresh install and conservative 0008 upgrade', () => { try { await admin.unsafe(`create database "${name}"`); } finally { await admin.end(); } url.pathname = '/' + name; const db = new DatabaseService(new ConfigService({ DATABASE_URL: url.toString() })); databases.push(db); - return { db, apply: async (source = folder) => { const sql = postgres(url.toString(), { max: 1 }); try { await migrate(drizzle(sql), { migrationsFolder: source }); } finally { await sql.end(); } } }; + return { db, apply: async (source = historical()) => { const sql = postgres(url.toString(), { max: 1 }); try { await migrate(drizzle(sql), { migrationsFolder: source }); } finally { await sql.end(); } } }; } afterAll(async () => { for (const db of databases) await db.onApplicationShutdown(); }); it('installs twelve migrations, narrow NOLOGIN owners and pinned definers with no PUBLIC publication capability', async () => { diff --git a/apps/api/test/integration/receipt-admission-migration.spec.ts b/apps/api/test/integration/receipt-admission-migration.spec.ts index e9c5abd..ab8b658 100644 --- a/apps/api/test/integration/receipt-admission-migration.spec.ts +++ b/apps/api/test/integration/receipt-admission-migration.spec.ts @@ -19,7 +19,7 @@ describeMigration('B3.2 R0 forward migration and dormant history',()=>{ const clients:DatabaseService[]=[],urls=new Map(),folder=resolve(__dirname,'../../drizzle'); async function isolated(){const value=process.env.MIGRATION_ADMIN_URL;if(!value)throw new Error('Disposable local admin URL required');const url=new URL(value);if(!['127.0.0.1','localhost','[::1]'].includes(url.hostname))throw new Error('Local cluster required'); const name=`fintech_r0_migration_${randomUUID().replaceAll('-','')}`,admin=postgres(value,{max:1});try{await admin.unsafe(`create database "${name}"`);}finally{await admin.end();}url.pathname=`/${name}`;const db=new DatabaseService(new ConfigService({DATABASE_URL:url.toString()}));clients.push(db);urls.set(db,url.toString());return db;} - async function apply(db:DatabaseService,path=folder){const sql=postgres(urls.get(db)!,{max:1});try{await migrate(drizzle(sql),{migrationsFolder:path});}finally{await sql.end();}} + async function apply(db:DatabaseService,path=staged(12)){const sql=postgres(urls.get(db)!,{max:1});try{await migrate(drizzle(sql),{migrationsFolder:path});}finally{await sql.end();}} function staged(before:number,failing=false){const path=resolve(__dirname,`../../../../.tmp/r0-migrations-${randomUUID()}`);mkdirSync(resolve(path,'meta'),{recursive:true});const journal=JSON.parse(readFileSync(resolve(folder,'meta/_journal.json'),'utf8')) as {entries:{idx:number;tag:string}[]};journal.entries=journal.entries.filter(e=>e.idx statement-breakpoint\nDO $$ BEGIN RAISE EXCEPTION 'planned R0 migration rollback'; END $$;\n");}return path;} afterAll(async()=>{for(const db of clients)await db.onApplicationShutdown();}); async function fingerprint(db:DatabaseService){const tables=['ledger_transactions','ledger_entries','capture_accounting_lots','refunds','refund_capture_allocations','provider_financial_receipts','provider_financial_observations','provider_financial_object_owners','webhook_financial_evidence','financial_evidence_reviews','webhook_events','accounting_exceptions'];const result:Record={};for(const table of tables)result[table]=await db.sql.unsafe(`select md5(coalesce(string_agg(to_jsonb(t)::text,'|' order by to_jsonb(t)::text),'')) as hash from public.${table} t`);result.functions=await db.sql`select md5(string_agg(pg_get_functiondef(p.oid),'|' order by p.proname)) as hash from pg_proc p where p.pronamespace='public'::regnamespace and p.proname in ('seal_financial_correlation','load_effective_financial_evidence','prepare_capture_allocation_write','check_capture_allocation_effect','protect_ledger_entry_insert')`;return result;} diff --git a/apps/api/test/integration/receipt-delivery-migration.spec.ts b/apps/api/test/integration/receipt-delivery-migration.spec.ts index e54e8f7..a72f51a 100644 --- a/apps/api/test/integration/receipt-delivery-migration.spec.ts +++ b/apps/api/test/integration/receipt-delivery-migration.spec.ts @@ -23,7 +23,7 @@ describeMigration('R0.2 additive delivery migration', () => { const db = new DatabaseService(new ConfigService({ DATABASE_URL: url.toString() })); clients.push(db); urls.set(db, url.toString()); return db; } - async function apply(db: DatabaseService, path = folder) { + async function apply(db: DatabaseService, path = staged(12)) { const sql = postgres(urls.get(db)!, { max: 1 }); try { await migrate(drizzle(sql), { migrationsFolder: path }); } finally { await sql.end(); } } diff --git a/apps/api/test/integration/settlement-accounting-integration.spec.ts b/apps/api/test/integration/settlement-accounting-integration.spec.ts index 04a92f1..2893e8c 100644 --- a/apps/api/test/integration/settlement-accounting-integration.spec.ts +++ b/apps/api/test/integration/settlement-accounting-integration.spec.ts @@ -371,7 +371,7 @@ describeDatabase('B2.4 dormant capture settlement transactions (legacy F01 remai const before=await Promise.all([snapshot(f),snapshot(zero),snapshot(pending)]); const client=postgres(process.env.DATABASE_URL!,{max:1});try{await migrate(drizzle(client),{migrationsFolder:resolve(__dirname,'../../drizzle')});}finally{await client.end();} expect(await Promise.all([snapshot(f),snapshot(zero),snapshot(pending)])).toEqual(before); - const [row]=await database.sql`select count(*)::integer as count from drizzle.__drizzle_migrations`;expect(row.count).toBe(12); + const [row]=await database.sql`select count(*)::integer as count from drizzle.__drizzle_migrations`;expect(row.count).toBe(13); expect(await complete(id)).toBe('PROCESSED'); }); diff --git a/apps/api/test/integration/shared-payment-proof-envelope-migration.spec.ts b/apps/api/test/integration/shared-payment-proof-envelope-migration.spec.ts new file mode 100644 index 0000000..4cea873 --- /dev/null +++ b/apps/api/test/integration/shared-payment-proof-envelope-migration.spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres, { type Sql } from 'postgres'; +import { providerObservationHarness } from '../support/provider-observation-harness'; +import { publishCommandObservation, signedEvidence } from '../support/trusted-evidence-fixtures'; + +const describeMigration = process.env.RUN_DB_TESTS === '1' && process.env.RUN_MIGRATION_TESTS === '1' ? describe : describe.skip; +describeMigration('P-A forward migration preserves legacy REFUND evidence and guards', () => { + const folder = resolve(__dirname,'../../drizzle'), all: Awaited>[] = []; + function staged(count = 12, failure = false) { + const target = resolve(__dirname,`../../../../.tmp/pa-migrations-${randomUUID()}`); mkdirSync(resolve(target,'meta'),{recursive:true}); + const journal = JSON.parse(readFileSync(resolve(folder,'meta/_journal.json'),'utf8')) as {entries:{idx:number;tag:string}[]}; + journal.entries=journal.entries.filter(e=>e.idx statement-breakpoint\nDO $$ BEGIN RAISE EXCEPTION 'planned P-A rollback'; END $$;\n"); } + return target; + } + async function setup(source = folder) { + const h=await providerObservationHarness(source); all.push(h); const url=new URL(process.env.MIGRATION_ADMIN_URL!); url.pathname='/'+String((await h.db.sql`select current_database() n`)[0].n); + async function apply(from=folder) { const sql=postgres(url.toString(),{max:1}); try { await migrate(drizzle(sql),{migrationsFolder:from}); } finally { await sql.end(); } } + return {h,url,apply}; + } + afterAll(async()=>{for(const h of all)await h.close();}); + async function legacy(h: Awaited>) { + const f=await h.accepted(); await h.db.transaction(tx=>publishCommandObservation(tx,f,'CAPTURE')); await(await f.producer.execute(f.message)).publish(); + await h.db.sql`insert into refund_capture_allocations(refund_id,capture_lot_id,payment_id,merchant_id,currency,reserved_gross) values(${f.operationId},${f.lotId},${f.paymentId},${f.merchantId},'USD',2000)`; + const signed=signedEvidence({...f,refundId:f.operationId,objectId:f.object.id},{object:{amount:2000}}),attested=h.sign(signed.raw); + const receipt=await h.receiver(f.account).receive(attested.raw,attested.signature); expect(await h.correlate(f.account).correlate(receipt.id)).toMatchObject({state:'BOUND'}); + const [root]=await h.db.sql<{id:string}[]>`insert into financial_application_roots(family,operation_id,phase,merchant_id,currency,provider,provider_account,policy,acceptance_epoch,accepted_outbox_id,payment_id,refund_id) + values('REFUND',${f.operationId},'TERMINAL',${f.merchantId},'USD','STRIPE',${f.account},'LEGACY',0,${f.outboxId},${f.paymentId},${f.operationId}) returning id`; + async function insert(inbox = receipt.id) { + return h.db.sql`insert into financial_application_manifests(root_id,transaction_id,backend_pid,inbox_id,proof_revision,proof_operation,claim_generation,merchant_id,currency,payment_id, + policy,epoch,scope_revision,intake_revision,contract_version,result,source_manifest,effect_manifest) + values(${root.id},pg_current_xact_id(),pg_backend_pid(),${inbox},1,'refund.succeeded',0,${f.merchantId},'USD',${f.paymentId}, + 'LEGACY',0,0,0,1,'ZERO_EFFECT','{}','[]') returning id`; + } + const [manifest]=await insert(); return {...f,inboxId:receipt.id,manifestId:String(manifest.id),insert}; + } + async function fingerprint(sql: Sql) { + const result: Record={}; + for(const table of ['financial_application_roots','financial_application_manifests','webhook_financial_evidence','payment_operation_evidence','provider_observation_sources','provider_command_executions','ledger_transactions','ledger_entries','refund_capture_allocations']) + result[table]=(await sql.unsafe(`select md5(coalesce(string_agg(j,'|' order by j),'')) hash from (select (to_jsonb(t)-'proof_kind'-'payment_operation_proof_id'-'legacy_proof_inbox_id'-'payment_proof_inbox_id')::text j from ${table} t) s`))[0].hash; + result.guards=await sql`select p.proname,pg_get_functiondef(p.oid) body,p.proacl::text acl from pg_proc p where p.pronamespace='public'::regnamespace and p.proname in + ('prepare_financial_application','validate_financial_application','complete_financial_application','financial_application_effect_owners','guard_financial_application_effect','check_financial_application_effect_finality','load_effective_payment_operation_evidence') order by p.proname`; + result.acls=await sql`select c.relname,coalesce(r.rolname,'PUBLIC') grantee,x.privilege_type,x.is_grantable,x.grantor::regrole::text grantor + from pg_class c cross join lateral aclexplode(c.relacl) x left join pg_roles r on r.oid=x.grantee + where c.oid in ('financial_application_manifests'::regclass,'webhook_financial_evidence'::regclass,'payment_operation_evidence'::regclass) + and coalesce(r.rolname,'PUBLIC')<>'fintech_proof_envelope_'||md5(current_database()) order by c.relname,grantee,x.privilege_type`; + result.triggers=await sql`select tgname,pg_get_triggerdef(oid) body from pg_trigger where tgrelid='financial_application_manifests'::regclass and not tgisinternal and tgname<>'application_payment_proof' order by tgname`; + return result; + } + it('fresh thirteen-migration install is repeatable, empty and closed', async()=>{ + const{h,apply}=await setup(); await apply(); expect((await h.db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(13); + for(const table of ['financial_application_contracts','financial_application_scope_authority','financial_application_manifests','financial_application_permits','financial_applications','financial_application_dispositions'])expect(await h.db.sql.unsafe(`select 1 from ${table}`)).toHaveLength(0); + },30000); + it('populated0011 upgrade retains exact original fields, ACLs, triggers, readers and REFUND default/FK', async()=>{ + const{h,apply}=await setup(staged()),f=await legacy(h),before=await fingerprint(h.db.sql); await apply(); await apply(); expect(await fingerprint(h.db.sql)).toEqual(before); + expect((await h.db.sql`select proof_kind,legacy_proof_inbox_id,payment_proof_inbox_id,payment_operation_proof_id from financial_application_manifests where id=${f.manifestId}`)[0]) + .toEqual({proof_kind:'REFUND_V1',legacy_proof_inbox_id:f.inboxId,payment_proof_inbox_id:null,payment_operation_proof_id:null}); + await f.insert(); + await expect(f.insert(randomUUID())).rejects.toMatchObject({code:'23503'}); + await expect(h.db.sql`update financial_application_manifests set proof_kind=proof_kind where id=${f.manifestId}`).rejects.toThrow(/immutable/i); + expect(await h.correlate(f.account).correlate(f.inboxId)).toMatchObject({state:'BOUND'}); + },30000); + it('late0012 failure restores the exact old catalog/data/owner state before a successful retry',async()=>{ + const{h,apply}=await setup(staged()); await legacy(h); const before=await fingerprint(h.db.sql); let error:unknown; + try{await apply(staged(13,true));}catch(e){error=e;} expect((error as {cause:Error}).cause.message).toMatch(/planned P-A rollback/); + expect(await fingerprint(h.db.sql)).toEqual(before); + expect((await h.db.sql`select exists(select 1 from pg_attribute where attrelid='financial_application_manifests'::regclass and attname='proof_kind' and not attisdropped) added, + to_regprocedure('public.check_payment_proof_envelope()') checker,exists(select 1 from pg_roles where rolname='fintech_proof_envelope_'||md5(current_database())) owner`)[0]).toEqual({added:false,checker:null,owner:false}); + expect((await h.db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(12); await apply(); + },30000); + it('observes a distinct backend waiting on a manifest writer without partial new branch visibility',async()=>{ + const{h,url,apply}=await setup(staged()); await legacy(h); let release!:()=>void,ready!:()=>void,holder=0; + const gate=new Promise(r=>{release=r;}),started=new Promise(r=>{ready=r;}); + const first=h.db.transaction(async tx=>{holder=Number((await tx`select pg_backend_pid() p`)[0].p);await tx`lock table financial_application_manifests in row exclusive mode`;ready();await gate;}); + const sql=postgres(url.toString(),{max:1});let pending:Promise|undefined; + try{await Promise.race([started,first]);const waiter=Number((await sql`select pg_backend_pid() p`)[0].p);expect(waiter).not.toBe(holder);pending=migrate(drizzle(sql),{migrationsFolder:folder});let observed=false; + for(let n=0;n<150;n++){if((await h.db.sql`select ${holder}=any(pg_blocking_pids(${waiter})) b`)[0].b){observed=true;break;}await delay(20);}expect(observed).toBe(true); + expect((await h.db.sql`select exists(select 1 from pg_attribute where attrelid='financial_application_manifests'::regclass and attname='proof_kind' and not attisdropped) added`)[0].added).toBe(false); + release();await Promise.all([first,pending]);await apply(); + }finally{release();await Promise.allSettled([first,...(pending?[pending]:[])]);await sql.end();} + },30000); +}); diff --git a/apps/api/test/integration/shared-payment-proof-envelope.spec.ts b/apps/api/test/integration/shared-payment-proof-envelope.spec.ts new file mode 100644 index 0000000..bff3b71 --- /dev/null +++ b/apps/api/test/integration/shared-payment-proof-envelope.spec.ts @@ -0,0 +1,168 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import postgres, { type Sql, type TransactionSql } from 'postgres'; +import { paymentOperationHarness } from '../support/payment-operation-harness'; + +const describeDb = process.env.RUN_DB_TESTS === '1' ? describe : describe.skip; +type Row = Record; +// A raw INSERT here proves inert storage only; it is never a P-B application. +async function insert(sql: Sql | TransactionSql, row: Row) { + const keys = Object.keys(row); + return sql.unsafe<{id:string}[]>(`insert into financial_application_manifests(transaction_id,backend_pid,${keys.join(',')}) + values(pg_current_xact_id(),pg_backend_pid(),${keys.map((key, i) => '$' + (i + 1) + (key.endsWith('_manifest') ? '::text::jsonb' : '')).join(',')}) returning id`, Object.values(row)); +} +describeDb('P-A shared immutable payment proof envelope', () => { + let h: Awaited>, structural: Sql, columns: Sql, ordinary: Sql; + type Fixture = Awaited>; + let f: Fixture, foreign: Fixture; + async function fixture(operation: 'CAPTURE' | 'AUTHORIZE' | 'VOID' = 'CAPTURE') { + const value = await h.fixture({ operation }), sealed = await value.seal(); expect(sealed.state).toBe('SEALED'); + await h.evidence.bind(value.inboxId, sealed.proof_id!, '0'); + const [root] = await h.db.sql`insert into financial_application_roots + (family,operation_id,phase,merchant_id,currency,provider,provider_account,policy,acceptance_epoch,accepted_outbox_id,payment_id,attempt_id) + values(${operation},${value.accepted.id},'TERMINAL',${value.merchantId},'USD','STRIPE',${value.account},'LEGACY',0,${value.accepted.outboxId},${value.paymentId},${value.accepted.id}) returning id`; + return { ...value, proofId: sealed.proof_id!, rootId: String(root.id) }; + } + function row(value = f): Row { + return { root_id: value.rootId, inbox_id: value.inboxId, proof_kind: 'PAYMENT_OPERATION_V1', payment_operation_proof_id: value.proofId, + proof_revision: 1, proof_operation: value.operation === 'CAPTURE' ? 'payment.capture_succeeded' : value.operation === 'AUTHORIZE' ? 'payment.authorized' : 'payment.cancelled', claim_generation: 0, + merchant_id: value.merchantId, currency: 'USD', payment_id: value.paymentId, policy: 'LEGACY', epoch: 0, scope_revision: 0, intake_revision: 0, + contract_version: 1, result: 'VERIFIED_NO_EFFECT', source_manifest: '{}', effect_manifest: '[]' }; + } + beforeAll(async () => { + h = await paymentOperationHarness(); f = await fixture(); foreign = await fixture(); + async function login(label: string) { + const role = `pa_${label}_${randomUUID().replaceAll('-', '')}`, password = randomBytes(32).toString('hex'); + await h.db.sql.unsafe(`create role "${role}" login password '${password}' noinherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls`); + await h.db.sql.unsafe(`grant usage on schema public to "${role}"`); + const url = new URL(process.env.MIGRATION_ADMIN_URL!); url.pathname = '/' + String((await h.db.sql`select current_database() n`)[0].n); url.username = role; url.password = password; + return { role, sql: postgres(url.toString(), { max: 1, connection: { statement_timeout: 10000 } }) }; + } + const storage = await login('structural'); structural = storage.sql; + const column = await login('columns'); columns = column.sql; ordinary = (await login('ordinary')).sql; + await h.db.sql.unsafe(`grant insert,select(id) on financial_application_manifests to "${storage.role}"`); + await h.db.sql.unsafe(`grant insert(proof_kind,payment_operation_proof_id),select(id) on financial_application_manifests to "${column.role}"`); + }, 30000); + afterAll(async () => { for (const sql of [structural, columns, ordinary]) if (sql) await sql.end(); if (h) await h.close(); }); + async function closed() { + for (const table of ['financial_application_contracts','financial_application_scope_authority','financial_application_permits','financial_applications','financial_application_dispositions','financial_application_revision_effects','ledger_transactions','capture_accounting_lots']) + expect(await h.db.sql.unsafe(`select 1 from ${table}`)).toHaveLength(0); + expect(await h.db.sql`select 1 from capture_accounting_scopes where status='ACTIVE'`).toHaveLength(0); + expect((await h.db.sql`select captured_amount::text from payments where id=${f.paymentId}`)[0].captured_amount).toBe('0'); + expect((await h.db.sql`select status from payment_attempts where id=${f.accepted.id}`)[0].status).toBe('PENDING'); + expect((await h.db.sql`select disposition from receipt_admission_obligations where inbox_id=${f.inboxId}`)[0].disposition).toBe('AWAITING_APPLICATION'); + } + it('stores genuine sealed/bound B1 CAPTURE through a separate structural LOGIN without application authority', async () => { + const [id] = await insert(structural, row()); + expect((await h.db.sql`select proof_kind,legacy_proof_inbox_id,payment_proof_inbox_id,payment_operation_proof_id from financial_application_manifests where id=${id.id}`)[0]) + .toEqual({ proof_kind:'PAYMENT_OPERATION_V1', legacy_proof_inbox_id:null, payment_proof_inbox_id:f.inboxId, payment_operation_proof_id:f.proofId }); + await closed(); + }); + it.each(['AUTHORIZE','VOID'] as const)('retains %s typed storage without opening workflow finality', async operation => { + const value = await fixture(operation); await insert(structural, row(value)); await closed(); + }); + it.each([ + ['null tag',{proof_kind:null}], ['invalid tag',{proof_kind:'CAPTURE'}], ['both branches',{proof_kind:'REFUND_V1'}], + ['no proof',{payment_operation_proof_id:null}], ['missing proof',{payment_operation_proof_id:randomUUID()}], + ['null inbox',{inbox_id:null}], ['null revision',{proof_revision:null}], ['wrong revision',{proof_revision:2}], + ['null owner',{payment_id:null}], ['wrong merchant',{merchant_id:randomUUID()}], ['wrong payment',{payment_id:randomUUID()}], + ['wrong currency',{currency:'EUR'}], ['wrong operation',{proof_operation:'payment.authorized'}], + ['wrong policy',{policy:'CAPTURE_FIFO_V1'}], ['wrong epoch',{epoch:1}], + ['caller legacy link',{legacy_proof_inbox_id:randomUUID()}], ['caller payment link',{payment_proof_inbox_id:randomUUID()}], + ] as [string, Row][])('rejects %s atomically', async (_name, override) => { + const before = (await h.db.sql<{n:number}[]>`select count(*)::int n from financial_application_manifests`)[0].n; + await expect(insert(structural, { ...row(), ...override })).rejects.toMatchObject({ code: expect.any(String) as string }); + expect((await h.db.sql`select count(*)::int n from financial_application_manifests`)[0].n).toBe(before); await closed(); + }); + it('rejects foreign inbox/proof/root tuples even with the same operation', async () => { + for (const override of [{ inbox_id:foreign.inboxId }, { payment_operation_proof_id:foreign.proofId }, { root_id:foreign.rootId }, + { inbox_id:foreign.inboxId,payment_operation_proof_id:foreign.proofId }] as Row[]) + await expect(insert(structural, { ...row(), ...override })).rejects.toThrow(); + await closed(); + }); + it('checks canonical attempt/outbox/family and refuses the default REFUND tag on payment roots', async () => { + const [root] = await h.db.sql`insert into financial_application_roots + (family,operation_id,phase,merchant_id,currency,provider,provider_account,policy,acceptance_epoch,accepted_outbox_id,payment_id,attempt_id) + values('AUTHORIZE',${f.auth.id},'TERMINAL',${f.merchantId},'USD','STRIPE',${f.account},'LEGACY',0,${f.auth.outboxId},${f.paymentId},${f.auth.id}) returning id`; + await expect(insert(structural, { ...row(),root_id:String(root.id) })).rejects.toThrow(/canonical root/); + const legacy = row(); delete legacy.proof_kind; delete legacy.payment_operation_proof_id; + await expect(insert(structural, legacy)).rejects.toThrow(/REFUND root/); + }); + it.each(['outcome','mode','account','source','execution','fingerprint','source facts','authorization facts','command hash','receipt hash'] as const)('rejects a forged %s before a deferred B1 seal can commit', async kind => { + const unsealed = await h.fixture(), id = randomUUID(); + await expect(h.db.transaction(async tx => { + const [proof] = await tx`select to_jsonb(e)-'id'-'inbox_id' original from payment_operation_evidence e where id=${f.proofId}`; + const fake: Record = { ...proof.original as Record, id, inbox_id:unsealed.inboxId }; + if (kind === 'outcome') Object.assign(fake,{operation:'payment.authorized',outcome:'AUTHORIZE_SUCCEEDED',capture_principal:null,authorized_principal:10000,authorization_source_id:null}); + if (kind === 'mode') fake.provider_mode='LIVE'; + if (kind === 'account') fake.provider_account=foreign.account; + if (kind === 'source') fake.source_id=foreign.sourceId; + if (kind === 'execution') fake.execution_id=randomUUID(); + if (kind === 'fingerprint') fake.fingerprint='\\x'+'00'.repeat(32); + if (kind.endsWith('facts') || kind.endsWith('hash')) { + const facts = fake.facts as Record; + if (kind === 'source facts') facts.source={...facts.source as object,source_fingerprint:'00'.repeat(32)}; + if (kind === 'authorization facts') facts.authorization={...facts.authorization as object,source_id:foreign.authSource}; + if (kind === 'command hash') facts.command_hash='00'.repeat(32); + if (kind === 'receipt hash') facts.receipt_hash='00'.repeat(32); + const [hash] = await tx`select '\\x'||encode(sha256(convert_to((${JSON.stringify(facts)}::text::jsonb)::text,'UTF8')),'hex') digest`; + fake.fingerprint=hash.digest; + } + await tx`insert into payment_operation_evidence select * from jsonb_populate_record(null::payment_operation_evidence,${JSON.stringify(fake)}::text::jsonb)`; + await insert(tx,{...row(),inbox_id:unsealed.inboxId,payment_operation_proof_id:id}); + throw new Error('forged proof unexpectedly accepted'); + })).rejects.toMatchObject({code:kind==='source'||kind==='execution'?'23503':'P0001'}); + expect(await h.db.sql`select 1 from payment_operation_evidence where id=${id}`).toHaveLength(0); await closed(); + }); + it('keeps manifests and original proof/source/execution history immutable including TRUNCATE', async () => { + for (const table of ['financial_application_manifests','payment_operation_evidence','provider_observation_sources','provider_command_executions']) { + await expect(h.db.sql.unsafe(`update ${table} set id=id`)).rejects.toThrow(/immutable/i); + await expect(h.db.sql.unsafe(`delete from ${table}`)).rejects.toThrow(/immutable/i); + await expect(h.db.sql.unsafe(`truncate ${table} cascade`)).rejects.toThrow(/immutable|truncat/i); + } + }); + it('rolls back storage after immediate constraint validation without permits or source clearance', async () => { + const before = (await h.db.sql<{n:number}[]>`select count(*)::int n from financial_application_manifests`)[0].n; + await expect(structural.begin(async tx => { await insert(tx,row()); await tx`set constraints all immediate`; throw new Error('late storage rollback'); })).rejects.toThrow('late storage rollback'); + expect((await h.db.sql`select count(*)::int n from financial_application_manifests`)[0].n).toBe(before); await closed(); + }); + it('retains deferred financial group rejection for an inert manifest', async () => { + await expect(h.db.transaction(async tx => { + const [m] = await insert(tx,row()); + await tx`insert into financial_application_revision_effects(manifest_id,root_id,prior_revision,final_revision) values(${m.id},${f.rootId},0,1)`; + await tx`set constraints all immediate`; + })).rejects.toThrow(); await closed(); + }); + it('denies ordinary and column-only LOGIN writes and direct protected checker calls', async () => { + await expect(insert(ordinary,row())).rejects.toMatchObject({code:'42501'}); + await expect(insert(columns,row())).rejects.toMatchObject({code:'42501'}); + await expect(columns`insert into financial_application_manifests(proof_kind,payment_operation_proof_id) values('PAYMENT_OPERATION_V1',${f.proofId})`).rejects.toThrow(); + for (const sql of [ordinary,columns,structural]) await expect(sql`select public.check_payment_proof_envelope()`).rejects.toMatchObject({code:'42501'}); + await expect(structural`select public.current_financial_application_manifest()`).rejects.toMatchObject({code:'42501'}); await closed(); + }); + it('uses a SELECT-only NOLOGIN owner with pinned search path and no PUBLIC execute/schema CREATE', async () => { + const [owner] = await h.db.sql<{rolname:string}[]>`select r.rolname,r.rolcanlogin,r.rolinherit,r.rolsuper,r.rolcreatedb,r.rolcreaterole,r.rolbypassrls,p.proconfig, + has_schema_privilege(r.oid,'public','CREATE') create_schema, + exists(select 1 from aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) where grantee=0 and privilege_type='EXECUTE') public_execute + from pg_proc p join pg_roles r on r.oid=p.proowner where p.oid='public.check_payment_proof_envelope()'::regprocedure`; + expect(owner).toMatchObject({rolcanlogin:false,rolinherit:false,rolsuper:false,rolcreatedb:false,rolcreaterole:false,rolbypassrls:false, + proconfig:['search_path=pg_catalog, pg_temp'],create_schema:false,public_execute:false}); + expect(await h.db.sql`select 1 from information_schema.role_table_grants where grantee=${owner.rolname} and privilege_type<>'SELECT'`).toHaveLength(0); + expect(await h.db.sql`select 1 from information_schema.column_privileges where grantee=${owner.rolname} and privilege_type<>'SELECT'`).toHaveLength(0); + expect(await h.db.sql`select 1 from pg_auth_members where roleid=(select oid from pg_roles where rolname=${owner.rolname})`).toHaveLength(0); + }); + it('refuses AUTHORIZE/CAPTURE/VOID preparation even with a registered private probe', async () => { + await h.db.sql`create function public.pa_closed_prepare(uuid) returns jsonb language sql as 'select ''{}''::jsonb'`; + await h.db.sql`create function public.pa_closed_validate(uuid) returns boolean language sql as 'select false'`; + for (const operation of ['AUTHORIZE','CAPTURE','VOID'] as const) { + const value = await fixture(operation); + await h.db.sql`insert into financial_application_contracts values(${operation},'TERMINAL','LEGACY',1,'public.pa_closed_prepare(uuid)'::regprocedure,'public.pa_closed_validate(uuid)'::regprocedure)`; + await expect(h.db.transaction(async tx => { + await tx`insert into financial_application_scope_authority values(${value.merchantId},'USD','STRIPE',${value.account},'LEGACY',0,'DORMANT')`; + await tx`update webhook_events set status='PROCESSING' where id=${value.inboxId}`; + await tx`select public.prepare_financial_application(${value.rootId},${value.inboxId},0,1,${row(value).proof_operation})`; + })).rejects.toThrow(/Typed application proof contract is not installed/); + } + expect(await h.db.sql`select 1 from financial_application_permits`).toHaveLength(0); + expect(await h.db.sql`select 1 from financial_applications`).toHaveLength(0); + }); +}); diff --git a/apps/api/test/integration/trusted-evidence-migration.spec.ts b/apps/api/test/integration/trusted-evidence-migration.spec.ts index 1e95389..880792c 100644 --- a/apps/api/test/integration/trusted-evidence-migration.spec.ts +++ b/apps/api/test/integration/trusted-evidence-migration.spec.ts @@ -23,7 +23,7 @@ describeMigration('B3.2 B.1 forward migration and closed financial floor',()=>{ const admin=postgres(value,{max:1}),name=`fintech_b32b1_migration_${randomUUID().replaceAll('-','')}`; try{await admin.unsafe(`create database "${name}"`);}finally{await admin.end();}url.pathname=`/${name}`; const db=new DatabaseService(new ConfigService({DATABASE_URL:url.toString()}));clients.push(db);urls.set(db,url.toString());return db;} - async function apply(db:DatabaseService,path=folder){const client=postgres(urls.get(db)!,{max:1});try{await migrate(drizzle(client),{migrationsFolder:path});}finally{await client.end();}} + async function apply(db:DatabaseService,path=stagedFolder(12)){const client=postgres(urls.get(db)!,{max:1});try{await migrate(drizzle(client),{migrationsFolder:path});}finally{await client.end();}} function stagedFolder(before:number,failing=false){const target=resolve(__dirname,`../../../../.tmp/b32b1-migrations-${randomUUID()}`);mkdirSync(resolve(target,'meta'),{recursive:true}); const journal=JSON.parse(readFileSync(resolve(folder,'meta/_journal.json'),'utf8')) as {entries:{idx:number;tag:string}[]};journal.entries=journal.entries.filter(e=>e.idx{ const gate=new Promise(r=>{release=r;}),started=new Promise(r=>{ready=r;});let blocker=0; const held=db.transaction(async tx=>{blocker=(await tx<{pid:number}[]>`select pg_backend_pid() as pid`)[0].pid;await tx`lock table public.refund_capture_allocations in row exclusive mode`;ready();await gate;}); const migration=postgres(urls.get(db)!,{max:1});let installing:Promise|undefined; - try{await Promise.race([started,held]);const waiter=(await migration<{pid:number}[]>`select pg_backend_pid() as pid`)[0].pid;installing=migrate(drizzle(migration),{migrationsFolder:folder}); + try{await Promise.race([started,held]);const waiter=(await migration<{pid:number}[]>`select pg_backend_pid() as pid`)[0].pid;installing=migrate(drizzle(migration),{migrationsFolder:stagedFolder(12)}); let blocked=false;for(let i=0;i<150;i++){const [r]=await db.sql<{blocked:boolean}[]>`select ${blocker}=any(pg_blocking_pids(${waiter})) as blocked`;if(r.blocked){blocked=true;break;}await delay(20);}expect(blocked).toBe(true); expect((await db.sql`select to_regclass('public.webhook_financial_evidence') as proof`)[0].proof).toBeNull();release();await Promise.all([held,installing]); expect((await db.sql`select count(*)::integer as count from drizzle.__drizzle_migrations`)[0].count).toBe(12); diff --git a/apps/api/test/support/provider-observation-harness.ts b/apps/api/test/support/provider-observation-harness.ts index 8020c9c..d9c393b 100644 --- a/apps/api/test/support/provider-observation-harness.ts +++ b/apps/api/test/support/provider-observation-harness.ts @@ -76,5 +76,5 @@ export async function providerObservationHarness(migrationsFolder?: string) { } return { receipt: h, db: h.db, sign: h.sign, receiver: h.receiver, correlate: h.correlate, read: h.read, observationRole: role, observationSql: sql, observationPeer: peer, publication, peerPublication, accepted, - async close() { for (const client of clients) await client.end(); await h.close(); } }; + async close(expectedMigrations = 13) { for (const client of clients) await client.end(); await h.close(expectedMigrations); } }; } diff --git a/apps/api/test/support/trusted-receipt-harness.ts b/apps/api/test/support/trusted-receipt-harness.ts index 95b40fd..c8e74bd 100644 --- a/apps/api/test/support/trusted-receipt-harness.ts +++ b/apps/api/test/support/trusted-receipt-harness.ts @@ -93,11 +93,11 @@ export async function trustedReceiptHarness(migrationsFolder = resolve(__dirname mkdirSync(resolve(__dirname, '../../../../.tmp'), { recursive: true }); writeFileSync(resolve(__dirname, '../../../../.tmp/r1-signed-integration-db.txt'), name + '\n'); return { db, roles, publisher, peer, correlator, correlationPeer, worker, publication, peerPublication, correlation, peerCorrelation, stripe, secret, receiver, correlate, sign, http, inspect, read, - async close() { + async close(expectedMigrations = 13) { try { expect((await db.sql`select count(*)::int n from capture_accounting_scopes where status='ACTIVE'`)[0].n).toBe(0); expect((await db.sql`select count(*)::int n from financial_evidence_accounts where runtime_enforcement<>'UNINSTALLED'`)[0].n).toBe(0); - expect((await db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(12); + expect((await db.sql`select count(*)::int n from drizzle.__drizzle_migrations`)[0].n).toBe(expectedMigrations); } finally { for (const app of apps) await app.close(); for (const sql of clients) await sql.end(); await db.onApplicationShutdown(); } } }; } diff --git a/docs/database-design.md b/docs/database-design.md index 623c817..5680b1e 100644 --- a/docs/database-design.md +++ b/docs/database-design.md @@ -96,3 +96,5 @@ Sealing and binding use separate READ COMMITTED transactions with inbox → acco [B1.1 correction](audits/h1-fix-02-b3-2-b2-r2-b1-1-contradictory-evidence-correction.md) extends the same SQL facts routine to all historical authorization command results and all causally related authenticated originals/later deliveries. Contradictions prevent sealing or invalidate effective reading without changing immutable proof history. Equivalent retries preserve facts; a current authorization lookup is not another historical zero baseline. Different operation keys/request IDs retain separate obligations, even on the same PI. Accounting and activation remain closed. [B1.2](audits/h1-fix-02-b3-2-b2-r2-b1-1-contradictory-evidence-correction.md#r2-b12-independent-review-findings-correction) applies the same signed-object predicate to the selected original and peers, including optional explicit capture method. A private duplicate-preserving causal parser establishes relevance before strict payment JSON parsing; clearly unrelated informational fields cannot invalidate another proof, while ambiguous identity and relevant duplicates remain blocking. Standalone and predecessor authorization share the same historical semantic comparison and original selected-source fingerprints. No financial schema, runtime grant, source clearance or application path is opened. + +[Migration0012](../apps/api/drizzle/0012_shared_payment_proof_envelope.sql) gives immutable application manifests an explicit `REFUND_V1` (the legacy default) or `PAYMENT_OPERATION_V1` tag. Generated inbox links and exclusive branch checks retain the original refund owner FK and require the exact owned B1 payment proof. A SELECT-only protected checker compares canonical root, accepted operation/account/mode/policy/epoch and immutable proof/source/execution/receipt provenance, without using current capture eligibility as historical authority. This is dormant storage only: REFUND preparation and financial validators retain their existing gates; no payment application contract, permit issuer, source discharge, enrollment or runtime grant is installed.