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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions backend/migrations/005_payment_split_records.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Payment Split Records for cross-account payment splitting (Issue #1720).
--
-- Stores the outcome of each individual charge within a split payment
-- for audit, revenue reconciliation, and dispute investigation.

CREATE TABLE IF NOT EXISTS payment_split_records (
id TEXT PRIMARY KEY,
-- The idempotency key of the parent split request.
idempotency_key TEXT NOT NULL,
-- Total amount of the parent split request, in smallest currency unit.
total_amount NUMERIC(20, 8) NOT NULL,
currency TEXT NOT NULL,
-- Whether every individual split charge succeeded.
all_succeeded BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_psr_idempotency_key ON payment_split_records (idempotency_key);
CREATE INDEX IF NOT EXISTS idx_psr_created_at ON payment_split_records (created_at);

COMMENT ON TABLE payment_split_records IS
'Top-level record for a cross-account split payment (Issue #1720). '
'Each row groups the per-destination entries in payment_split_entries.';

-- ─── Per-entry detail ────────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS payment_split_entries (
id TEXT PRIMARY KEY,
split_record_id TEXT NOT NULL REFERENCES payment_split_records (id) ON DELETE CASCADE,
destination_account_id TEXT NOT NULL,
percentage NUMERIC(7, 4) NOT NULL,
amount NUMERIC(20, 8) NOT NULL,
status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed', 'pending')),
charge_id TEXT,
gateway_used TEXT,
error_message TEXT,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_pse_split_record_id ON payment_split_entries (split_record_id);
CREATE INDEX IF NOT EXISTS idx_pse_destination_account ON payment_split_entries (destination_account_id);
CREATE INDEX IF NOT EXISTS idx_pse_status ON payment_split_entries (status);

COMMENT ON TABLE payment_split_entries IS
'Per-destination charge result within a split payment. '
'One row per PaymentSplitEntry in the original request.';

COMMENT ON COLUMN payment_split_entries.percentage IS 'Percentage of the total amount directed to this destination (0–100).';
COMMENT ON COLUMN payment_split_entries.amount IS 'Actual amount charged to this destination, in smallest currency unit.';
COMMENT ON COLUMN payment_split_entries.charge_id IS 'Gateway-returned charge/transaction identifier.';
COMMENT ON COLUMN payment_split_entries.error_message IS 'Non-null only when status = ''failed''.';

-- @down
DROP TABLE IF EXISTS payment_split_entries;
DROP TABLE IF EXISTS payment_split_records;
8 changes: 8 additions & 0 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import { PaddleAdapter } from '../services/payment/domain/gateways/PaddleAdapter
import { PaddleWebhookVerifier } from '../services/payment/domain/paddle/PaddleWebhookVerifier';
import { createPaddleRouter } from '../services/payment/router/paddleRouter';

// ── Payment splitting (issue #1720) ───────────────────────────────────────────
import { createPaymentSplitRouter } from '../services/payment/router/paymentSplitRouter';

// ── Calendar + renewal reminders ──────────────────────────────────────────────
import { calendarSyncService } from '../calendar/domain/CalendarSyncService';
import { createCalendarSyncController } from '../calendar/controller/calendarSyncController';
Expand Down Expand Up @@ -310,6 +313,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {
}),
);

// ── Payment splitting (issue #1720) ────────────────────────────────────────
// POST /api/v1/payments/split — split a charge across multiple destination accounts.
// The router itself adds the /payments prefix to the route path.
app.use('/api/v1', createPaymentSplitRouter());

// ── Webhook signature verification demo endpoint ───────────────────────────
// Uses captureRawBody only on this specific route so the stream isn't
// consumed before express.json() processes all other routes.
Expand Down
Loading
Loading