Skip to main content

Executive Summary

As CONA expands to serve SaaS businesses with subscription billing (Paddle, Stripe, Chargebee), we need proper accrual accounting for prepaid revenue. This RFC proposes a deferred revenue recognition system that: Key Goals:
  • Correctly recognize revenue over the service period (not when payment received)
  • Comply with German HGB (§252 Abs. 1 Nr. 5) and IFRS 15 requirements
  • Provide clear visibility into deferred revenue liabilities
  • Handle cancellations and refunds gracefully
  • Automate monthly revenue recognition via Temporal workflows
Architecture Highlights:
  • 🎯 Document-based: Integrations populate deferred_revenue_start_date, deferred_revenue_end_date, deferred_revenue_period on invoices
  • ⚙️ Posting Matrix Interception: System intercepts posting matrix execution for prepaid invoices and redirects revenue → PRAP account
  • 📅 Schedule-based: One schedule per invoice, generates monthly recognition entries
  • 🔄 Automated: Monthly Temporal workflow recognizes revenue without manual intervention
Not Goals:
  • Revenue recognition for usage-based billing (separate feature)
  • Multi-element arrangements (split between services)
  • Contract modifications mid-period (v2 feature)

How It Works: End-to-End Flow

┌─────────────────────────────────────────────────────────────────────┐
│ STEP 1: Integration Import                                          │
│ (Paddle/Stripe/Chargebee imports annual subscription)              │
└─────────────────────────────────────────────────────────────────────┘


                 ┌────────────────────────┐
                 │ Create Invoice Doc     │
                 │ amount: €1,200         │
                 │ date: 2024-01-01       │
                 │                        │
                 │ ✅ Deferred fields:    │
                 │ start: 2024-01-01     │
                 │ end: 2024-12-31       │
                 │ period: MONTHLY        │
                 └────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ STEP 2: Execute Posting Matrix                                      │
│ Posting Matrix says: DR 1200 (Debitor), CR 8400 (Revenue)          │
└─────────────────────────────────────────────────────────────────────┘


                 ┌────────────────────────┐
                 │ System detects:        │
                 │ Document has           │
                 │ deferred_revenue_*     │
                 │ fields set!            │
                 └────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ STEP 3: ✅ INTERCEPT Posting Matrix Result                         │
│                                                                      │
│ Original (from posting matrix):                                     │
│   DR: 1200 (Debitor) €1,200                                        │
│   CR: 8400 (Revenue) €1,200                                        │
│                                                                      │
│ ⚡ System intercepts and modifies credit side:                      │
│   DR: 1200 (Debitor) €1,200                                        │
│   CR: 2610 (PRAP) €1,200  ← System replaces Revenue with PRAP     │
│                                                                      │
│ Create deferred_revenue_schedule:                                   │
│   total: €1,200                                                     │
│   original_revenue_account_id: 8400 (for future recognition)       │
│   deferred_revenue_account_id: 2610                                │
│   status: active                                                    │
└─────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ STEP 4: Monthly Workflow (Runs Jan 31, Feb 28, etc.)               │
│                                                                      │
│ For each active schedule:                                           │
│   1. Calculate monthly amount (€1,200 / 12 = €100)                 │
│   2. ✅ CREATE GENERAL LEDGER ENTRIES (attached to sales invoice):  │
│      DR: 2610 (PRAP) €100                                          │
│      CR: 8400 (Revenue) €100  ← From schedule.target_revenue_*     │
│      document_id: source_document_id (the sales invoice)           │
│   3. Update schedule.recognized_amount += €100                     │
└─────────────────────────────────────────────────────────────────────┘


                 ┌────────────────────────┐
                 │ After 12 months:       │
                 │ - 12 GL entries on     │
                 │   the sales invoice   │
                 │ - All €1,200 recognized│
                 │ - Schedule: completed  │
                 │ - PRAP balance: €0     │
                 └────────────────────────┘

Problem Statement

Current State

CONA currently records subscription payments as immediate revenue:
Customer pays €1,200 for annual SaaS subscription (Jan 1)
Current behavior:
  DR: Bank Account €1,200
  CR: Revenue €1,200  ❌ INCORRECT - not compliant

Problem: Revenue shown in January P&L, but service spans 12 months
This violates:
  • HGB §252 (1) Nr. 5: Realization principle - revenue only when earned
  • IFRS 15: Revenue from contracts with customers over performance period
  • Matching Principle: Revenue must match period when service delivered

German Accounting Requirements

Rechnungsabgrenzungsposten (ARAP) - Required by German law:
  • Passive RAP (2610): Prepayments received for future services = Liability
  • Must be amortized over service period (monthly for subscriptions)
  • SKR03/SKR04: Account 2610 “Passive Rechnungsabgrenzungsposten”

Business Impact

Current Problems:
  1. Inflated Revenue: Annual subscriptions show 12x revenue in month 1
  2. Incorrect P&L: January shows €100k revenue, Feb-Dec show €0
  3. Missing Liabilities: Balance sheet doesn’t show obligation to deliver services
  4. Tax Issues: Advance VAT recognition without service delivery
  5. Cancellation Chaos: No clear way to handle mid-year cancellations

Proposed Solution

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                    PADDLE TRANSACTION                            │
│  Transaction ID: txn_abc123                                      │
│  Amount: €1,200                                                  │
│  Billing Period: Jan 1 - Dec 31, 2024                          │
└────────────────────────┬────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                    SALES INVOICE (Document)                      │
│  ID: doc_001                                                     │
│  Type: sales-invoice                                             │
│  Amount: €1,200                                                  │
│  Status: completed                                               │
│  Date: 2024-01-01                                                │
│                                                                  │
│  ✅ Deferred Revenue Fields (set by integration):               │
│    deferred_revenue_start_date: 2024-01-01                      │
│    deferred_revenue_end_date: 2024-12-31                        │
│    deferred_revenue_period: MONTHLY                             │
│    deferred_revenue_schedule_id: null (not yet created)         │
│                                                                  │
│  Custom Properties:                                              │
│    paddle_transaction_id: txn_abc123                            │
│    paddle_subscription_id: sub_xyz                              │
└──────────────────────────┬──────────────────────────────────────┘

                           ▼ Accounting Impact Created
┌─────────────────────────────────────────────────────────────────┐
│                    GENERAL LEDGER ENTRY                          │
│  DR: 1800 (Bank) €1,200                                         │
│  CR: 2610 (Deferred Revenue) €1,200 ← Account from            │
│       tools.settings.prap_account_id                            │
└──────────────────────────┬──────────────────────────────────────┘

                           ▼ System detects deferred revenue via tool settings
                           ▼ Reads document.deferred_revenue_* fields
                           ▼ Auto-creates schedule!
┌─────────────────────────────────────────────────────────────────┐
│              DEFERRED REVENUE SCHEDULE (Tracking)                │
│  ID: drs_001                                                     │
│  source_document_id: doc_001                                     │
│  deferred_revenue_account_id: acc_2610                          │
│  target_revenue_account_id: acc_8401                            │
│                                                                  │
│  Total: €1,200        Recognized: €300 (25%)                    │
│  Remaining: €900                                                 │
│  Status: active       Period: Jan 1 - Dec 31, 2024             │
│                                                                  │
│  Recognition: monthly (€100/month)                              │
└────────────────────────┬────────────────────────────────────────┘

                         │ Monthly workflow creates GL entries
                         │ (attached to the sales invoice)

                         ├──────────────┬──────────────┬──────────┐
                         ▼              ▼              ▼          ▼
         ┌─────────────────────────────────────────────────────────┐
         │     GENERAL LEDGER ENTRIES (Created Monthly)             │
         │     document_id → doc_001 (sales invoice)                │
         ├─────────────────────────────────────────────────────────┤
         │ Jan: GL entry (€100) ✓  → DR: 2610 CR: 8401            │
         │                                                          │
         │ Feb: GL entry (€100) ✓  → DR: 2610 CR: 8401            │
         │                                                          │
         │ Mar: GL entry (€100) ✓  → DR: 2610 CR: 8401            │
         │                                                          │
         │ Apr: (pending - runs Apr 30)                            │
         │ May: (pending)                                           │
         │ ...                                                      │
         └─────────────────────────────────────────────────────────┘

Data Model

1. Extend Documents Table

Add new columns for deferred revenue configuration and schedule link:
enum DEFERRED_REVENUE_PERIOD {
  DAILY     // Daily recognition (no proration needed)
  WEEKLY    // Weekly recognition using ISO weeks (Mon-Sun), prorated for partial weeks
  MONTHLY   // Monthly recognition (calendar months), prorated for partial months
  QUARTERLY // Quarterly recognition, prorated for partial quarters
  YEARLY    // Yearly recognition, prorated for partial years
}

model documents {
  id                           String    @id @default(cuid())
  // ... existing fields ...

  // NEW: Deferred revenue fields (set by integration when importing)
  deferred_revenue_start_date  DateTime?                // Service period start
  deferred_revenue_end_date    DateTime?                // Service period end
  deferred_revenue_period      DEFERRED_REVENUE_PERIOD? // Recognition frequency (type-safe enum)

  // NEW: Link to generated schedule (set when accounting impact created)
  deferred_revenue_schedule_id String?   @unique
  deferred_revenue_schedule    deferred_revenue_schedules? @relation(fields: [deferred_revenue_schedule_id], references: [id])

  // Integration-specific metadata stays in custom_properties
  custom_properties            Json?
}
Document Example:
import { DEFERRED_REVENUE_PERIOD } from '@cona/database';

// Sales Invoice Document (created by Paddle integration)
{
  id: "doc_001",
  type: "sales-invoice",
  amount: 1200,
  status: "completed",
  date: "2024-01-01",

  // ✅ Deferred revenue fields (populated by integration)
  deferred_revenue_start_date: "2024-01-01",
  deferred_revenue_end_date: "2024-12-31",
  deferred_revenue_period: DEFERRED_REVENUE_PERIOD.MONTHLY, // Type-safe enum

  // ✅ Schedule link (null until accounting impact created)
  deferred_revenue_schedule_id: null, // Will be set when posted to deferred revenue account

  // ✅ Integration metadata in custom_properties
  custom_properties: {
    paddle_transaction_id: "txn_abc123",
    paddle_subscription_id: "sub_xyz",
  }
}
Why this design?
  • Type-safe ENUM: Prevents typos and invalid values at compile time
  • Deferred revenue data on document: Integration sets it once during import
  • Account-based trigger: Posting to deferred revenue account automatically creates schedule
  • Flexible: Works for any integration (Paddle, Stripe, Chargebee, manual invoices)
  • Fallback-friendly: If fields empty, can use document date + default duration
  • Proration built-in: First/last periods automatically prorated by days

2. Deferred Revenue Tool Configuration

Use the existing tools table to store deferred revenue configuration:
model tools {
  id           String       @id @default(cuid())
  tool_slug    String       // "deferred_revenue"
  settings     Json         // Tool-specific configuration
  org_id       String
  created_at   DateTime     @default(now())
  updated_at   DateTime     @updatedAt
  organization organization @relation(fields: [org_id], references: [id])

  @@unique([tool_slug, org_id])
  @@index([org_id])
}
Deferred Revenue Tool Record:
// tools record
{
  org_id: "org_123",
  tool_slug: "deferred_revenue",
  settings: {
    prap_account_id: "acc_2610",     // Which account is PRAP (2610)
    default_period: "MONTHLY"        // Fallback if document.deferred_revenue_period empty
  }
}
Integration requirements:
  • Integrations must set deferred_revenue_start_date and deferred_revenue_end_date
  • These come from the subscription billing period (always available from Paddle/Stripe/Chargebee)
  • If missing, system will reject the document (not silently fallback)
  • Only deferred_revenue_period can use fallback (defaults to MONTHLY)
Revenue recognition:
  • Monthly Temporal workflow automatically processes all active schedules
  • Schedule status controls whether revenue is recognized:
    • active → Recognized automatically by workflow
    • completed → All revenue already recognized
    • cancelled → No further recognition (e.g., customer refunded)
    • suspended → Temporarily paused (e.g., payment issues)
Recognition timing:
  • Revenue is always recognized at the end of each period
  • MONTHLY: Last day of month (Jan 31, Feb 28/29, Mar 31, etc.)
  • WEEKLY: Last day of ISO week (Sunday)
  • DAILY: Each day
  • QUARTERLY: Last day of quarter (Mar 31, Jun 30, Sep 30, Dec 31)
  • YEARLY: Last day of year (Dec 31)
This is standard accounting practice and matches regulatory requirements.

**Why this design?**
- ✅ **Uses existing infrastructure**: No new table needed
- ✅ **Separation of concerns**: Chart of Accounts stays pure accounting
- ✅ **Flexible configuration**: Can add/change settings without schema migrations
- ✅ **Consistent with CONA architecture**: Tools already used for integrations
- ✅ **Per-organization**: Each org can configure independently
- ✅ **UI-friendly**: Settings managed in existing "Tools" section

**Accessing the configuration:**

```typescript
// Fetch deferred revenue tool settings
const deferredRevenueTool = await prisma.tools.findUnique({
  where: {
    tool_slug_org_id: {
      tool_slug: "deferred_revenue",
      org_id: organizationId
    }
  }
});

const settings = deferredRevenueTool?.settings as {
  prap_account_id: string;
  default_period: DEFERRED_REVENUE_PERIOD;
};

// Get PRAP account
const prapAccount = await prisma.chart_of_accounts.findUnique({
  where: { id: settings.prap_account_id }
});

3. New Table: deferred_revenue_schedules

CREATE TABLE deferred_revenue_schedules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,

  -- Link to source invoice document
  source_document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,

  -- Account references (for automatic document creation)
  deferred_revenue_account_id UUID NOT NULL REFERENCES chart_of_accounts(id), -- 2610
  target_revenue_account_id UUID NOT NULL REFERENCES chart_of_accounts(id),   -- 8401

  -- Financial tracking
  total_amount NUMERIC(15,2) NOT NULL CHECK (total_amount > 0),
  recognized_amount NUMERIC(15,2) NOT NULL DEFAULT 0 CHECK (recognized_amount >= 0),
  remaining_amount NUMERIC(15,2) GENERATED ALWAYS AS (total_amount - recognized_amount) STORED,
  currency_code TEXT NOT NULL DEFAULT 'EUR',

  -- Schedule configuration
  start_date DATE NOT NULL,
  end_date DATE NOT NULL,
  recognition_frequency TEXT NOT NULL CHECK (recognition_frequency IN ('DAILY', 'WEEKLY', 'MONTHLY', 'QUARTERLY', 'YEARLY')),

  -- Status
  status TEXT NOT NULL CHECK (status IN ('active', 'completed', 'cancelled', 'suspended')),
  cancelled_at TIMESTAMP WITH TIME ZONE,
  cancellation_reason TEXT,
  cancellation_document_id UUID REFERENCES documents(id), -- links to credit note document

  -- Metadata
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  created_by UUID REFERENCES users(id),

  CONSTRAINT valid_date_range CHECK (end_date > start_date),
  CONSTRAINT valid_recognized_amount CHECK (recognized_amount <= total_amount),
  CONSTRAINT unique_source_document UNIQUE (source_document_id)
);

-- Indexes
CREATE INDEX idx_drs_org_status ON deferred_revenue_schedules(org_id, status);
CREATE INDEX idx_drs_accounts ON deferred_revenue_schedules(deferred_revenue_account_id, target_revenue_account_id);
CREATE INDEX idx_drs_recognition_dates ON deferred_revenue_schedules(start_date, end_date) WHERE status = 'active';
CREATE INDEX idx_drs_source_doc ON deferred_revenue_schedules(source_document_id);
Key Fields:
  • deferred_revenue_account_id: The liability account (2610) - used as debit account for monthly recognition GL entries
  • target_revenue_account_id: The revenue account (8401) - captured from posting matrix at interception, used as credit account for monthly recognition GL entries
  • cancellation_document_id: Links to credit note document

#### 4. New Table: `revenue_recognition_entries`

```sql
CREATE TABLE revenue_recognition_entries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  schedule_id UUID NOT NULL REFERENCES deferred_revenue_schedules(id) ON DELETE CASCADE,

  -- Link to general_ledger entry (GL entry attached to source sales invoice)
  general_ledger_id UUID REFERENCES general_ledger(id) ON DELETE SET NULL,

  -- Recognition details
  recognition_date DATE NOT NULL,
  amount NUMERIC(15,2) NOT NULL CHECK (amount > 0),
  period TEXT NOT NULL, -- 'YYYY-MM' format for easy grouping

  -- Status
  status TEXT NOT NULL CHECK (status IN ('pending', 'recognized', 'cancelled', 'failed')),
  error_message TEXT, -- if status = 'failed'

  -- Metadata
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  recognized_at TIMESTAMP WITH TIME ZONE, -- when GL entry was created

  CONSTRAINT unique_schedule_period UNIQUE (schedule_id, period)
);

-- Indexes
CREATE INDEX idx_rre_schedule ON revenue_recognition_entries(schedule_id, recognition_date);
CREATE INDEX idx_rre_status_date ON revenue_recognition_entries(status, recognition_date);
CREATE INDEX idx_rre_period ON revenue_recognition_entries(period) WHERE status = 'pending';
CREATE INDEX idx_rre_general_ledger ON revenue_recognition_entries(general_ledger_id);
Key Design:
  • GL entries are created directly and attached to the source sales invoice (document_id on general_ledger = source invoice)
  • No new document type; recognition entries link to general_ledger for audit trail
  • Accounts come from the schedule: deferred_revenue_account_id (DR) and target_revenue_account_id (CR)

Implementation Details

1. Transaction Import Flow (Paddle)

Step 1: Integration populates deferred revenue fields on document
// packages/temporal-workflows/src/activities/paddle/process-transaction.ts

async function processPaddleTransactionActivity(transaction: PaddleTransaction) {
  const { items, billing_period, customer_id, total } = transaction;

  // 1. Extract billing cycle from price
  const price = items[0].price;
  const billingCycle = price.billing_cycle; // { interval: "year", frequency: 1 }

  // 2. Determine if should defer revenue
  const shouldDefer = shouldDeferRevenue(billingCycle); // true for annual/quarterly

  // 3. Create sales invoice document with deferred revenue fields
  const invoice = await createDocument({
    type: "sales-invoice",
    amount: total,
    currency: transaction.currency_code,
    customer_id: customer_id,
    date: transaction.created_at,

    // ✅ NEW: Set deferred revenue fields (type-safe with enum)
    deferred_revenue_start_date: shouldDefer ? billing_period?.starts_at : null,
    deferred_revenue_end_date: shouldDefer ? billing_period?.ends_at : null,
    deferred_revenue_period: shouldDefer ? DEFERRED_REVENUE_PERIOD.MONTHLY : null,

    custom_properties: {
      paddle_transaction_id: transaction.id,
      paddle_subscription_id: transaction.subscription_id,
    },
  });

  // 4. Create accounting impact
  // The system will automatically detect deferred revenue account
  // and create schedule based on document fields
  await createAccountingImpact({
    documentId: invoice.id,
    entries: shouldDefer
      ? [
          { account: "1800", debit: total }, // Bank Account
          { account: "2610", credit: total }, // Deferred Revenue ← Triggers schedule!
        ]
      : [
          { account: "1800", debit: total }, // Bank Account
          { account: "8401", credit: total }, // SaaS Revenue
        ],
  });
}

function shouldDeferRevenue(billingCycle: BillingCycle): boolean {
  if (billingCycle.interval === "year") return true;
  if (billingCycle.interval === "month" && billingCycle.frequency >= 3) return true;
  return false;
}
Step 2: System intercepts posting matrix for prepaid invoices
// @cona/core/accounting/create-accounting-impact.ts

// Helper: Get Deferred Revenue Configuration
async function getDeferredRevenueConfig(organizationId: string) {
  return await prisma.tools.findUnique({
    where: {
      tool_slug_org_id: {
        tool_slug: "deferred_revenue",
        org_id: organizationId,
      },
    },
  });
}

// Type-safe settings access
type DeferredRevenueSettings = {
  prap_account_id: string;
  default_period: DEFERRED_REVENUE_PERIOD;
};

export async function createAccountingImpact({ documentId }: CreateAccountingImpactParams) {
  const document = await getDocument(documentId);

  // 1. Execute posting matrix to get the accounting entries
  const postingMatrixResult = await executePostingMatrix({
    documentType: document.type,
    document,
  });

  // postingMatrixResult = [
  //   { account: "1200", debit: 1200 },  // Debitor
  //   { account: "8400", credit: 1200 }  // Revenue (from posting matrix)
  // ]

  // 2. ✅ CHECK: Is this a prepaid invoice?
  const isPrepaidInvoice =
    document.deferred_revenue_start_date && document.deferred_revenue_end_date;

  let finalEntries = postingMatrixResult;
  let originalRevenueAccount = null;

  if (isPrepaidInvoice) {
    // 3. ✅ INTERCEPT: Redirect revenue to PRAP account
    const deferredRevenueTool = await getDeferredRevenueConfig(organizationId);

    if (!deferredRevenueTool?.settings?.prap_account_id) {
      throw new Error(
        "PRAP account not configured. Please configure deferred revenue tool settings."
      );
    }

    const prapAccount = await prisma.chart_of_accounts.findUnique({
      where: { id: deferredRevenueTool.settings.prap_account_id },
    });

    if (!prapAccount) {
      throw new Error(`PRAP account ${deferredRevenueTool.settings.prap_account_id} not found`);
    }

    // Find the revenue (credit) entry from posting matrix
    const revenueEntry = postingMatrixResult.find((e) => e.credit && e.credit > 0);
    originalRevenueAccount = revenueEntry?.account;

    // Replace revenue account with PRAP account
    finalEntries = postingMatrixResult.map((entry) => {
      if (entry.credit && entry.credit > 0) {
        // This is the revenue entry - replace with PRAP
        return {
          ...entry,
          account: prapAccount.account_nr, // 2610 instead of 8400
          _originalAccount: originalRevenueAccount, // Store for logging
        };
      }
      return entry;
    });

    console.log(
      `Prepaid invoice detected: Redirecting ${originalRevenueAccount}${prapAccount.account_nr} (PRAP)`
    );
  }

  // 4. Create GL entries with (potentially modified) accounts
  for (const entry of finalEntries) {
    await createGeneralLedger({
      documentId,
      accountCode: entry.account,
      debit: entry.debit,
      credit: entry.credit,
    });
  }

  // 5. ✅ LOG INTERCEPTION to audit logs
  if (isPrepaidInvoice) {
    await log({
      message: "Deferred revenue interception applied",
      level: "info",
      metadata: {
        documentId: document.id,
        documentNumber: document.nr,
        interceptionType: "prepaid_invoice",
        originalAccount: originalRevenueAccount,
        modifiedAccount: prapAccount.account_nr,
        amount: postingMatrixResult.find((e) => e.credit)?.credit || 0,
        deferralPeriod: {
          start: document.deferred_revenue_start_date,
          end: document.deferred_revenue_end_date,
          frequency: document.deferred_revenue_period,
        },
        reason: "Prepaid subscription - revenue will be recognized monthly",
      },
    });

    // Also create activity log entry visible in document history
    await createActivityLog({
      action: "deferred_revenue_interception",
      documentId: document.id,
      actorId: "system", // System action
      details: `Posting matrix intercepted: Revenue account ${originalRevenueAccount} → PRAP account ${prapAccount.account_nr}. Revenue will be recognized monthly from ${format(document.deferred_revenue_start_date, "MMM yyyy")} to ${format(document.deferred_revenue_end_date, "MMM yyyy")}.`,
      metadata: {
        interception: {
          originalAccount: originalRevenueAccount,
          prapAccount: prapAccount.account_nr,
          scheduleId: null, // Will be set after schedule creation
        },
      },
    });
  }

  // 6. ✅ CREATE SCHEDULE if prepaid
  if (isPrepaidInvoice && originalRevenueAccount) {
    const schedule = await createDeferredRevenueSchedule({
      document,
      prapAccount,
      originalRevenueAccount,
      amount: postingMatrixResult.find((e) => e.credit)?.credit || 0,
    });

    // Update activity log with schedule ID
    await updateActivityLog({
      documentId: document.id,
      action: "deferred_revenue_interception",
      updateMetadata: {
        "interception.scheduleId": schedule.id,
      },
    });
  }
}

async function createDeferredRevenueSchedule({
  document,
  prapAccount,
  originalRevenueAccount,
  amount,
  deferredRevenueTool,
}: CreateScheduleParams) {
  // 1. Get period and dates from document fields (set by integration)
  const period = document.deferred_revenue_period || deferredRevenueTool.settings.default_period;
  const startDate = document.deferred_revenue_start_date;
  const endDate = document.deferred_revenue_end_date;

  // 2. VALIDATION: Dates are required - integration must provide them
  if (!startDate || !endDate) {
    throw new Error(
      `Document ${document.id} missing required deferred revenue dates. ` +
        `Integration must set deferred_revenue_start_date and deferred_revenue_end_date ` +
        `from subscription billing period.`
    );
  }

  if (!document.deferred_revenue_period) {
    console.warn(
      `Document ${document.id} missing deferred_revenue_period. ` +
        `Using fallback: ${period} from tool settings`
    );
  }

  // 3. Create deferred revenue schedule
  const schedule = await prisma.deferred_revenue_schedules.create({
    data: {
      source_document_id: document.id,
      org_id: document.org_id,

      total_amount: amount,
      currency_code: document.currency,

      start_date: startDate,
      end_date: endDate,
      recognition_frequency: period, // Already DEFERRED_REVENUE_PERIOD enum value

      deferred_revenue_account_id: prapAccount.id, // 2610 (PRAP)
      target_revenue_account_id: originalRevenueAccount, // 8400 (from posting matrix)
      status: "active",
    },
  });

  // 4. Link schedule back to document
  await prisma.documents.update({
    where: { id: document.id },
    data: {
      deferred_revenue_schedule_id: schedule.id,
    },
  });

  console.log(
    `Created deferred revenue schedule ${schedule.id}: ` +
      `${amount} from ${prapAccount.account_nr}${originalRevenueAccount} ` +
      `over ${differenceInMonths(endDate, startDate)} months`
  );
}
// packages/temporal-workflows/src/activities/paddle/process-transaction.ts

async function processPaddleTransactionActivity(transaction: PaddleTransaction) {
  const { items, billing_period, customer_id, total } = transaction;

  // 1. Extract billing cycle from price
  const price = items[0].price;
  const billingCycle = price.billing_cycle; // { interval: "year", frequency: 1 }

  // 2. Create sales invoice document
  const invoice = await createDocument({
    type: "sales-invoice",
    amount: total,
    currency: transaction.currency_code,
    customer_id: customer_id,
    date: transaction.created_at,

    custom_properties: {
      paddle_transaction_id: transaction.id,
      paddle_subscription_id: transaction.subscription_id,
      paddle_billing_period_start: billing_period?.starts_at,
      paddle_billing_period_end: billing_period?.ends_at,
    },
  });

  // 3. Create deferred revenue schedule (if applicable)
  if (shouldDeferRevenue(billingCycle)) {
    const schedule = await createDeferredRevenueSchedule({
      source_document_id: invoice.id,
      integration_slug: "paddle",
      integration_id: integrationId,
      external_transaction_id: transaction.id,
      external_subscription_id: transaction.subscription_id,

      total_amount: total,
      currency_code: transaction.currency_code,

      start_date: billing_period.starts_at,
      end_date: billing_period.ends_at,
      recognition_frequency: DEFERRED_REVENUE_PERIOD.MONTHLY,
      status: "active",
    });

    // Update invoice with schedule reference (proper FK)
    await updateDocument(invoice.id, {
      deferred_revenue_schedule_id: schedule.id,
    });
  }

  // 4. Create accounting impact (initial booking)
  await createAccountingImpact({
    documentId: invoice.id,
    entries: shouldDeferRevenue(billingCycle)
      ? [
          // Prepayment booking
          { account: "1800", debit: total }, // Bank Account
          { account: "2610", credit: total }, // Deferred Revenue (Liability)
        ]
      : [
          // Immediate recognition (monthly subscriptions)
          { account: "1800", debit: total }, // Bank Account
          { account: "8401", credit: total }, // SaaS Revenue
        ],
  });
}

function shouldDeferRevenue(billingCycle: BillingCycle): boolean {
  // Defer revenue for:
  // - Annual subscriptions
  // - Quarterly subscriptions (3+ months)
  // - Multi-year subscriptions

  if (billingCycle.interval === "year") return true;
  if (billingCycle.interval === "month" && billingCycle.frequency >= 3) return true;

  return false;
}

2. Monthly Revenue Recognition Workflow

Creates general ledger entries attached to the sales invoice
// packages/temporal-workflows/src/workflows/revenue-recognition-workflow.ts

export async function monthlyRevenueRecognitionWorkflow() {
  const today = getCurrentUtcTime();
  const lastDayOfMonth = getLastDayOfMonth(today);
  const period = format(today, "yyyy-MM");

  // 1. Fetch active schedules that need recognition this month
  const schedules = await fetchSchedulesForRecognition({
    recognitionDate: lastDayOfMonth,
    status: "active",
  });

  console.log(`Processing ${schedules.length} deferred revenue schedules for ${period}`);

  // 2. Process each schedule - CREATE GL ENTRIES attached to source invoice
  const results = await Promise.allSettled(
    schedules.map((schedule) =>
      recognizeRevenueForSchedule({
        schedule,
        recognitionDate: lastDayOfMonth,
        period,
      })
    )
  );

  // 3. Log results
  const succeeded = results.filter((r) => r.status === "fulfilled").length;
  const failed = results.filter((r) => r.status === "rejected").length;

  console.log(`Revenue recognition complete: ${succeeded} succeeded, ${failed} failed`);

  return {
    totalSchedules: schedules.length,
    succeeded,
    failed,
    period,
  };
}

async function recognizeRevenueForSchedule({
  schedule,
  recognitionDate,
  period,
}: RecognizeRevenueParams) {
  // 1. Calculate amount to recognize this month
  const monthlyAmount = calculateMonthlyRecognitionAmount(schedule);

  // 2. Check if already recognized for this period
  const existing = await findRevenueRecognitionEntry({
    schedule_id: schedule.id,
    period,
  });

  if (existing) {
    console.log(`Already recognized for schedule ${schedule.id} in ${period}`);
    return existing;
  }

  // 3. ✅ CREATE GL ENTRY attached to source sales invoice
  //    Accounts from schedule: DR deferred_revenue_account_id, CR target_revenue_account_id
  const glEntry = await prisma.general_ledger.create({
    data: {
      document_id: schedule.source_document_id, // ← Attached to sales invoice
      debit_account_id: schedule.deferred_revenue_account_id,
      credit_account_id: schedule.target_revenue_account_id,
      amount: monthlyAmount,
      currency: schedule.currency_code,
      transaction_date: recognitionDate,
      label: `Deferred revenue recognition ${period}`,
      org_id: schedule.org_id,
      // ... accounting_period_id, dimensions, etc.
    },
  });

  // 4. Create tracking entry
  const recognitionEntry = await prisma.revenue_recognition_entries.create({
    data: {
      schedule_id: schedule.id,
      general_ledger_id: glEntry.id,
      recognition_date: recognitionDate,
      amount: monthlyAmount,
      period,
      status: "recognized",
      recognized_at: new Date(),
    },
  });

  // 5. Update schedule totals
  const newRecognizedAmount = schedule.recognized_amount + monthlyAmount;
  const isComplete = newRecognizedAmount >= schedule.total_amount;

  await prisma.deferred_revenue_schedules.update({
    where: { id: schedule.id },
    data: {
      recognized_amount: newRecognizedAmount,
      status: isComplete ? "completed" : "active",
      updated_at: new Date(),
    },
  });

  console.log(
    `Created GL entry ${glEntry.id} for schedule ${schedule.id}: ${monthlyAmount} (${period})`
  );

  return recognitionEntry;
}

function calculateMonthlyRecognitionAmount(schedule: DeferredRevenueSchedule): number {
  const totalMonths =
    differenceInMonths(new Date(schedule.end_date), new Date(schedule.start_date)) + 1; // +1 because inclusive

  const monthlyAmount = schedule.total_amount / totalMonths;

  // Handle rounding - last month gets remainder
  const recognizedMonths = Math.floor(schedule.recognized_amount / monthlyAmount);
  const isLastMonth = recognizedMonths === totalMonths - 1;

  if (isLastMonth) {
    return schedule.total_amount - schedule.recognized_amount;
  }

  return monthlyAmount;
}

3. Accounting Logic for Recognition

GL entries use accounts from the schedule:
  • Debit: deferred_revenue_account_id (PRAP, e.g. 2610) from the schedule
  • Credit: target_revenue_account_id (e.g. 8401) from the schedule — stored when the posting matrix intercepts the original invoice
No posting matrix is used for recognition; accounts are determined at schedule creation time from the intercepted invoice’s posting matrix result.

4. Cancellation Handling

// packages/temporal-workflows/src/activities/paddle/handle-subscription-cancellation.ts

export async function handleSubscriptionCancellationActivity({
  paddleSubscriptionId,
  cancellationDate,
  refundAmount,
}: CancellationParams) {
  // 1. Find active deferred revenue schedule
  const schedule = await findDeferredRevenueSchedule({
    external_subscription_id: paddleSubscriptionId,
    status: "active",
  });

  if (!schedule) {
    console.log(`No active deferred revenue schedule for subscription ${paddleSubscriptionId}`);
    return;
  }

  // 2. Calculate what happens to remaining balance
  const remainingAmount = schedule.remaining_amount;

  // 3. Create credit note for unearned portion
  const creditNote = await createDocument({
    type: "sales-credit-note",
    amount: refundAmount || remainingAmount,
    currency: schedule.currency_code,
    date: cancellationDate,

    // Link to original invoice
    references: [
      {
        document_id: schedule.source_document_id,
        reference_type: "credits",
      },
    ],

    custom_properties: {
      paddle_subscription_id: paddleSubscriptionId,
      paddle_adjustment_id: null, // Set when Paddle creates adjustment
      cancellation_reason: "subscription_cancelled",
    },
  });

  // 4. Close the schedule
  await updateDeferredRevenueSchedule(schedule.id, {
    status: "cancelled",
    cancelled_at: new Date(cancellationDate),
    cancellation_reason: "subscription_cancelled",
    cancellation_adjustment_id: creditNote.id,
  });

  // 5. Create accounting impact for cancellation
  await createAccountingImpact({
    documentId: creditNote.id,
    entries:
      refundAmount > 0
        ? [
            // With refund
            { account: "2610", debit: remainingAmount }, // Clear deferred revenue
            { account: "1800", credit: refundAmount }, // Refund from bank
          ]
        : [
            // No refund (pro-rated to cancellation date)
            { account: "2610", debit: remainingAmount }, // Clear deferred revenue
            { account: "6900", credit: remainingAmount }, // Cancellation adjustment
          ],
  });

  return {
    schedule_id: schedule.id,
    credit_note_id: creditNote.id,
    remaining_amount: remainingAmount,
    refund_amount: refundAmount,
  };
}

UI/UX Implementation

1. Chart of Accounts Configuration

Setup page: /setup/chart-of-accounts
// apps/webapp/app/(pages)/(settings)/setup/chart-of-accounts/page.tsx

// When editing account 2610
<AccountEditForm account={account}>
  {/* Existing fields */}
  <Input label="Account Number" value="2610" />
  <Input label="Label" value="Passive Rechnungsabgrenzungsposten" />
  <Select label="Account Type" value="LIABILITY" />

  {/* NEW: Deferred Revenue Toggle */}
  <div className="space-y-4 mt-6 p-4 border rounded-lg">
    <div className="flex items-center justify-between">
      <div>
        <Label>Deferred Revenue Account</Label>
        <p className="text-sm text-muted-foreground">
          Automatically create revenue recognition schedules when posting to this account
        </p>
      </div>
      <Switch checked={isDeferredRevenueAccount} onCheckedChange={setIsDeferredRevenueAccount} />
    </div>

    {isDeferredRevenueAccount && (
      <div className="space-y-4 pl-4 border-l-2 border-blue-500">
        {/* Target Revenue Account */}
        <div>
          <Label>Target Revenue Account *</Label>
          <Select value={targetAccountId}>
            <option value="">Select account...</option>
            <option value="acc_8401">8401 - Erlöse aus Software-Abonnements</option>
            <option value="acc_8400">8400 - Allgemeine Erlöse</option>
          </Select>
          <p className="text-sm text-muted-foreground">
            Revenue will be recognized to this account monthly
          </p>
        </div>

        {/* Default Duration (Fallback) */}
        <div>
          <Label>Default Duration (Fallback)</Label>
          <Input
            type="number"
            value={defaultMonths}
            onChange={(e) => setDefaultMonths(e.target.value)}
            min={1}
            max={60}
          />
          <p className="text-sm text-muted-foreground">
            Used when invoice doesn't specify deferred revenue dates (typically from integrations)
          </p>
        </div>

        {/* Info Alert */}
        <Alert className="bg-blue-50 border-blue-200">
          <InfoIcon className="h-4 w-4 text-blue-600" />
          <AlertTitle>How it works</AlertTitle>
          <AlertDescription className="text-sm">
            <ol className="list-decimal list-inside space-y-1 mt-2">
              <li>
                Integration imports invoice with <code>deferred_revenue_start_date</code> and{" "}
                <code>deferred_revenue_end_date</code>
              </li>
              <li>Invoice posts to this account (2610)</li>
              <li>System automatically creates revenue recognition schedule</li>
              <li>Monthly workflow recognizes revenue to target account (8401)</li>
            </ol>
          </AlertDescription>
        </Alert>
      </div>
    )}
  </div>
</AccountEditForm>

2. Sales Invoice Document View

// apps/webapp/app/(pages)/(documents)/documents/[id]/page.tsx

export default async function DocumentPage({ params }) {
  const document = await getDocument(params.id);
  const hasDeferredRevenue = !!document.deferred_revenue_schedule_id;

  return (
    <div>
      {/* Existing invoice details */}
      <DocumentHeader document={document} />
      <DocumentLineItems document={document} />

      {/* NEW: Deferred Revenue Card */}
      {hasDeferredRevenue && (
        <DeferredRevenueCard
          documentId={document.id}
          scheduleId={document.deferred_revenue_schedule_id}
        />
      )}

      {/* Accounting Impact Card - shows interception */}
      <AccountingImpactCard document={document} />

      {/* Activity Log - shows interception event */}
      <ActivityLogCard document={document} />
    </div>
  );
}

Activity Log Display

When viewing the invoice, users will see an activity log entry:
┌─────────────────────────────────────────────────────────────┐
│ Activity Log                                                 │
├─────────────────────────────────────────────────────────────┤
│ 🤖 System • 2 minutes ago                                   │
│ Deferred Revenue Interception                               │
│                                                              │
│ Posting matrix intercepted: Revenue account 8400 → PRAP     │
│ account 2610. Revenue will be recognized monthly from       │
│ Jan 2024 to Dec 2024.                                       │
│                                                              │
│ Details:                                                     │
│ • Original account: 8400 (SaaS Revenue)                     │
│ • PRAP account: 2610 (Passive RAP)                          │
│ • Amount: €1,200                                            │
│ • Schedule: View Recognition Schedule →                     │
└─────────────────────────────────────────────────────────────┘

2. Deferred Revenue Card Component

// apps/webapp/app/ui/documents/deferred-revenue-card.tsx

export function DeferredRevenueCard({ scheduleId }: { scheduleId: string }) {
  const { data: schedule } = useDeferredRevenueSchedule(scheduleId);

  if (!schedule) return null;

  const progressPercent = (schedule.recognized_amount / schedule.total_amount) * 100;
  const nextRecognition = getNextRecognitionDate(schedule);

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          <CalendarClock className="w-5 h-5" />
          Deferred Revenue Recognition
        </CardTitle>
        <CardDescription>Revenue is being recognized over the service period</CardDescription>
      </CardHeader>

      <CardContent className="space-y-4">
        {/* Progress Bar */}
        <div className="space-y-2">
          <div className="flex justify-between text-sm">
            <span className="text-muted-foreground">Recognition Progress</span>
            <span className="font-medium">{progressPercent.toFixed(0)}%</span>
          </div>
          <Progress value={progressPercent} className="h-2" />
          <div className="flex justify-between text-sm text-muted-foreground">
            <span>{formatCurrency(schedule.recognized_amount)} recognized</span>
            <span>{formatCurrency(schedule.remaining_amount)} remaining</span>
          </div>
        </div>

        {/* Status Badge */}
        <div className="flex items-center gap-4">
          <div>
            <div className="text-sm text-muted-foreground">Status</div>
            <Badge variant={getStatusVariant(schedule.status)}>{schedule.status}</Badge>
          </div>

          <div>
            <div className="text-sm text-muted-foreground">Service Period</div>
            <div className="text-sm font-medium">
              {formatDate(schedule.start_date)} - {formatDate(schedule.end_date)}
            </div>
          </div>

          {schedule.status === "active" && nextRecognition && (
            <div>
              <div className="text-sm text-muted-foreground">Next Recognition</div>
              <div className="text-sm font-medium">
                {formatDate(nextRecognition)} ({formatCurrency(schedule.monthly_amount)})
              </div>
            </div>
          )}
        </div>

        {/* Quick Stats */}
        <div className="grid grid-cols-3 gap-4 pt-4 border-t">
          <div>
            <div className="text-2xl font-bold">{formatCurrency(schedule.total_amount)}</div>
            <div className="text-sm text-muted-foreground">Total Amount</div>
          </div>
          <div>
            <div className="text-2xl font-bold text-green-600">
              {formatCurrency(schedule.recognized_amount)}
            </div>
            <div className="text-sm text-muted-foreground">Recognized</div>
          </div>
          <div>
            <div className="text-2xl font-bold text-orange-600">
              {formatCurrency(schedule.remaining_amount)}
            </div>
            <div className="text-sm text-muted-foreground">Deferred (Liability)</div>
          </div>
        </div>

        {/* Actions */}
        <div className="flex gap-2 pt-4">
          <Button variant="outline" asChild>
            <Link href={`/accounting/deferred-revenue/${schedule.id}`}>
              <Eye className="w-4 h-4 mr-2" />
              View Recognition Schedule
            </Link>
          </Button>

          {schedule.status === "active" && (
            <Button variant="outline" onClick={() => handleManualRecognition(schedule.id)}>
              <Zap className="w-4 h-4 mr-2" />
              Recognize Revenue Now
            </Button>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

3. Deferred Revenue Schedule Detail Page

// apps/webapp/app/(pages)/(accounting)/accounting/deferred-revenue/[id]/page.tsx

export default async function DeferredRevenueSchedulePage({ params }) {
  const schedule = await getDeferredRevenueSchedule(params.id);
  const entries = await getRevenueRecognitionEntries(params.id);
  const sourceInvoice = await getDocument(schedule.source_document_id);

  return (
    <div className="space-y-6">
      {/* Header */}
      <div>
        <h1 className="text-3xl font-bold">Revenue Recognition Schedule</h1>
        <p className="text-muted-foreground">
          Tracking revenue recognition for Invoice{" "}
          <Link href={`/documents/${sourceInvoice.id}`}>{sourceInvoice.document_number}</Link>
        </p>
      </div>

      {/* Summary Card */}
      <Card>
        <CardContent className="pt-6">
          <div className="grid grid-cols-4 gap-6">
            <StatCard
              label="Total Amount"
              value={formatCurrency(schedule.total_amount)}
              icon={<DollarSign />}
            />
            <StatCard
              label="Recognized"
              value={formatCurrency(schedule.recognized_amount)}
              trend={`${progressPercent}%`}
              icon={<TrendingUp />}
            />
            <StatCard
              label="Remaining"
              value={formatCurrency(schedule.remaining_amount)}
              icon={<Clock />}
            />
            <StatCard
              label="Monthly Amount"
              value={formatCurrency(schedule.monthly_amount)}
              icon={<Calendar />}
            />
          </div>
        </CardContent>
      </Card>

      {/* Recognition Timeline */}
      <Card>
        <CardHeader>
          <CardTitle>Recognition Timeline</CardTitle>
          <CardDescription>Monthly revenue recognition entries for this schedule</CardDescription>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Period</TableHead>
                <TableHead>Recognition Date</TableHead>
                <TableHead>Amount</TableHead>
                <TableHead>Status</TableHead>
                <TableHead>GL Entry</TableHead>
                <TableHead>Actions</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {entries.map((entry) => (
                <TableRow key={entry.id}>
                  <TableCell className="font-medium">{entry.period}</TableCell>
                  <TableCell>{formatDate(entry.recognition_date)}</TableCell>
                  <TableCell>{formatCurrency(entry.amount)}</TableCell>
                  <TableCell>
                    <Badge variant={getStatusVariant(entry.status)}>{entry.status}</Badge>
                  </TableCell>
                  <TableCell>
                    {entry.general_ledger_id ? (
                      <Link href={`/accounting/entries/${entry.general_ledger_id}`}>
                        <Button variant="link" size="sm">
                          View Entry →
                        </Button>
                      </Link>
                    ) : (
                      <span className="text-muted-foreground"></span>
                    )}
                  </TableCell>
                  <TableCell>
                    {entry.status === "pending" && (
                      <Button variant="ghost" size="sm" onClick={() => recognizeNow(entry.id)}>
                        Recognize Now
                      </Button>
                    )}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </CardContent>
      </Card>

      {/* Cancellation Section (if cancelled) */}
      {schedule.status === "cancelled" && (
        <Alert variant="destructive">
          <AlertTriangle className="h-4 w-4" />
          <AlertTitle>Schedule Cancelled</AlertTitle>
          <AlertDescription>
            This schedule was cancelled on {formatDate(schedule.cancelled_at)}. Reason:{" "}
            {schedule.cancellation_reason}
            {schedule.cancellation_adjustment_id && (
              <Link href={`/documents/${schedule.cancellation_adjustment_id}`}>
                View Credit Note →
              </Link>
            )}
          </AlertDescription>
        </Alert>
      )}
    </div>
  );
}

German Accounting Compliance

Chart of Accounts (SKR03)

1800 - Bank
2610 - Passive Rechnungsabgrenzungsposten (Deferred Revenue Liability)
8401 - Erlöse aus Software-Abonnements (SaaS Subscription Revenue)
6900 - Sonstige Aufwendungen (Other Expenses - for cancellations)

Example Bookings

Initial Payment (Jan 1, 2024):
Beleg: Sales Invoice INV-2024-001
Soll 1800 (Bank)                    €1,200.00
Haben 2610 (Passive RAP)            €1,200.00
Buchungstext: "Annual SaaS subscription received"
Monthly Recognition (Jan 31, 2024):
Beleg: GL Entry (attached to INV-2024-001)
Soll 2610 (Passive RAP)             €100.00
Haben 8401 (SaaS Revenue)           €100.00
Buchungstext: "Revenue recognition January 2024"
Cancellation with Refund (Apr 15, 2024):
Beleg: Credit Note CN-2024-001
Soll 2610 (Passive RAP)             €900.00  (remaining 9 months)
Haben 1800 (Bank)                   €900.00  (refund)
Buchungstext: "Subscription cancelled - refund unearned revenue"

Temporal Workflow Schedule

Monthly Revenue Recognition

// Schedule: Last day of every month at 23:00 UTC
const scheduleId = "monthly-revenue-recognition";

await client.schedule.create({
  scheduleId,
  spec: {
    calendars: [
      {
        dayOfMonth: -1, // Last day of month
        hour: 23,
        minute: 0,
      },
    ],
  },
  action: {
    type: "startWorkflow",
    workflowType: monthlyRevenueRecognitionWorkflow,
    taskQueue: "accounting-operations",
  },
});

Migration Plan

Phase 1: Foundation (Week 1)

  • Create database tables
  • Add core functions for schedule management
  • Implement shouldDeferRevenue() logic

Phase 2: Integration (Week 2)

  • Update Paddle transaction processing
  • Test with sandbox transactions
  • Add custom properties to documents

Phase 3: Recognition (Week 3)

  • Implement monthly workflow
  • Test recognition entries
  • Schedule Temporal workflow

Phase 4: UI (Week 4)

  • Build DeferredRevenueCard component
  • Create schedule detail page
  • Add to document view

Phase 5: Cancellations (Week 5)

  • Implement cancellation handler
  • Test refund scenarios
  • Webhook integration

Testing Strategy

Unit Tests

  • Schedule creation logic
  • Amount calculation (monthly, daily)
  • Rounding edge cases
  • Cancellation scenarios

Integration Tests

  • Full Paddle transaction → schedule → recognition flow
  • Cancellation with refund
  • Multiple subscriptions per customer

E2E Tests

  • Import annual subscription
  • Verify deferred revenue booking
  • Trigger monthly workflow
  • Check GL entries created
  • Cancel and verify credit note

Proration Implementation ✅

Decision: Automatic Proration for Partial Periods

Status: IMPLEMENTED in CONA-740 All partial periods (first/last months, weeks, etc.) are automatically prorated by days to ensure accurate revenue recognition.

MONTHLY Proration Example

Scenario: €120 for 12-month subscription starting Jan 15, 2024
Service period: Jan 15, 2024Jan 14, 2025
Total: €120
Base monthly amount: €120 ÷ 12 =10/month

Recognition schedule with proration:
1. Jan 15-31 (2024):  16 days / 31 days × €10 =5.16PRORATED
2. Feb 1-29 (2024):   29 days / 29 days × €10 =10.00 (full month, leap year)
3. Mar 1-31 (2024):   31 days / 31 days × €10 =10.00 (full month)
4. Apr 1-30 (2024):   30 days / 30 days × €10 =10.00 (full month)
...
11. Dec 1-31 (2024):  31 days / 31 days × €10 =10.00 (full month)
12. Jan 1-14 (2025):  14 days / 31 days × €10 =4.52PRORATED

Total: €120.00 (exact, last period gets remainder to prevent rounding drift)

WEEKLY Proration Example (ISO Weeks)

Scenario: €52 for 52-week subscription starting Wednesday (mid-week)
Service period: Wed, Jan 3, 2024Tue, Jan 2, 2025
Total: €52
Base weekly amount: €52 ÷ 52 =1/week
ISO Week: Monday-Sunday

Recognition schedule with proration:
1. Wed-Sun (Week 1):  5 days / 7 days × €1 =0.71PRORATED (partial week)
2. Mon-Sun (Week 2):  7 days / 7 days × €1 =1.00  (full week)
3. Mon-Sun (Week 3):  7 days / 7 days × €1 =1.00  (full week)
...
51. Mon-Sun (Week 51): 7 days / 7 days × €1 =1.00 (full week)
52. Mon-Tue (Week 52): 2 days / 7 days × €1 =0.29PRORATED (partial week)

Total: €52.00 (exact)

Proration Formula

function calculateProratedAmount(
  baseAmount: number,
  periodStart: Date,
  periodEnd: Date,
  frequency: DEFERRED_REVENUE_PERIOD
): number {
  switch (frequency) {
    case DEFERRED_REVENUE_PERIOD.MONTHLY: {
      const daysInMonth = getDaysInMonth(periodStart);
      const daysInPeriod = differenceInDays(periodEnd, periodStart) + 1; // Inclusive
      return (baseAmount * daysInPeriod) / daysInMonth;
    }

    case DEFERRED_REVENUE_PERIOD.WEEKLY: {
      const daysInPeriod = differenceInDays(periodEnd, periodStart) + 1; // Inclusive
      return (baseAmount * daysInPeriod) / 7;
    }

    case DEFERRED_REVENUE_PERIOD.DAILY:
      return baseAmount; // No proration needed - already granular

    case DEFERRED_REVENUE_PERIOD.QUARTERLY: {
      const daysInQuarter =
        differenceInDays(getEndOfQuarter(periodStart), getStartOfQuarter(periodStart)) + 1;
      const daysInPeriod = differenceInDays(periodEnd, periodStart) + 1;
      return (baseAmount * daysInPeriod) / daysInQuarter;
    }

    case DEFERRED_REVENUE_PERIOD.YEARLY: {
      const daysInYear = isLeapYear(periodStart) ? 366 : 365;
      const daysInPeriod = differenceInDays(periodEnd, periodStart) + 1;
      return (baseAmount * daysInPeriod) / daysInYear;
    }
  }
}

Rounding Strategy

Last period gets the remainder to prevent floating-point drift:
// Calculate all periods except last
const recognitions = periods.slice(0, -1).map((period) => ({
  period,
  amount: calculateProratedAmount(baseAmount, period.start, period.end, frequency),
}));

const totalRecognized = recognitions.reduce((sum, r) => sum + r.amount, 0);

// Last period = remaining amount (ensures exact total)
const lastRecognition = {
  period: periods[periods.length - 1],
  amount: totalAmount - totalRecognized,
};

recognitions.push(lastRecognition);

// Verify: recognitions.reduce((sum, r) => sum + r.amount, 0) === totalAmount ✅

Benefits

IFRS 15 Compliant - Revenue proportional to performance obligation ✅ Accurate - Matches service delivery exactly ✅ Handles Edge Cases - Leap years, varying month lengths, partial weeks ✅ No Manual Adjustment - Fully automated ✅ Exact Totals - No rounding errors (last period correction)

Implementation Status

  • CONA-740: Database schema with DEFERRED_REVENUE_PERIOD enum
  • 🔲 Future: Schedule creation with proration calculation
  • 🔲 Future: Monthly recognition workflow
See: /apps/internal-docs/implementation-notes/deferred-revenue-proration.md

Open Questions

  1. Partial Periods: How to handle subscriptions starting mid-month?RESOLVED
    • Decision: Auto-prorate first and last periods by days (implemented in CONA-740)
  2. Upgrades/Downgrades: How to handle plan changes mid-period?
    • Proposal: Phase 2 feature - create new schedule, prorate old
  3. Multiple Currencies: How to handle FX changes over recognition period?
    • Proposal: Lock FX rate at transaction date (IFRS approach)
  4. Daily vs Monthly: Should we support daily recognition?RESOLVED
    • Decision: Support DAILY, WEEKLY, MONTHLY, QUARTERLY, YEARLY via enum (implemented in CONA-740)
  5. Manual Adjustments: Can accountants manually adjust schedules?
    • Proposal: Yes, with audit trail and approval workflow

Risks & Mitigation

RiskImpactMitigation
Complex cancellation logicHighStart with simple refund scenarios, iterate
Performance (many schedules)MediumIndex optimization, batch processing, pagination
Wrong amounts recognizedHighExtensive testing, audit logs, manual reconciliation tools
Missed monthly runsHighTemporal retry policies, monitoring alerts, manual catch-up
Integration bugsMediumTest with sandbox first, gradual rollout per integration

Success Metrics

  • Correctness: 100% of deferred revenue schedules match expected recognition
  • Compliance: Pass external audit review for HGB/IFRS compliance
  • Performance: Process 10,000+ schedules in <5 minutes
  • Reliability: 99.9% success rate for monthly recognition runs
  • User Experience: <2 seconds to load schedule detail page

Appendix: Example Scenarios

Scenario 1: Annual Subscription

Customer: Acme Corp
Plan: Pro Annual
Amount: €1,200
Period: Jan 1 - Dec 31, 2024

Initial Booking (Jan 1):
  DR: Bank €1,200
  CR: Deferred Revenue €1,200

Monthly Recognition (Jan 31, Feb 29, ... Dec 31):
  DR: Deferred Revenue €100
  CR: Revenue €100

Result: €100/month revenue recognized over 12 months

Scenario 2: Quarterly Subscription

Customer: StartupXYZ
Plan: Starter Quarterly
Amount: €300
Period: Jan 1 - Mar 31, 2024

Initial Booking (Jan 1):
  DR: Bank €300
  CR: Deferred Revenue €300

Monthly Recognition (Jan 31, Feb 29, Mar 31):
  DR: Deferred Revenue €100
  CR: Revenue €100

Result: €100/month revenue recognized over 3 months

Scenario 3: Cancellation After 3 Months

Customer: CancelCorp
Plan: Pro Annual
Amount: €1,200
Period: Jan 1 - Dec 31, 2024
Cancelled: Apr 15, 2024

Recognized so far:
  Jan: €100 ✓
  Feb: €100 ✓
  Mar: €100 ✓
  Total: €300

Cancellation (Apr 15):
  Credit Note for €900 (remaining 9 months)
  DR: Deferred Revenue €900
  CR: Bank €900 (refund)

Result: €300 revenue recognized, €900 refunded

References