@saasicat/spec 1.0.0-rc.25 → 1.0.0-rc.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@ openapi: 3.1.0
29
29
 
30
30
  info:
31
31
  title: SaaS Platform SuperAdmin API
32
- version: 1.0.0-rc.25
32
+ version: 1.0.0-rc.26
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -387,6 +387,45 @@ paths:
387
387
  '404':
388
388
  description: No tenant with this slug (`TENANT_NOT_FOUND`)
389
389
 
390
+ /invoices/{id}/document:
391
+ parameters:
392
+ - name: id
393
+ in: path
394
+ required: true
395
+ description: The invoice's id
396
+ schema: { type: string }
397
+ get:
398
+ tags: [subscriptions]
399
+ summary: The document an invoice was issued as
400
+ description: >-
401
+ Served where the platform keeps invoices
402
+ (`tenantBilling.chargeJournal.invoices`) and shows tenants
403
+ (`adminResources`); the manifest announces it as
404
+ `invoices.read`. Answers the file archived for the invoice,
405
+ checked against the checksum recorded when it was rendered —
406
+ never a new rendering. Its media type is the one the renderer
407
+ gave, a PDF for a ZUGFeRD invoice.
408
+ responses:
409
+ '200':
410
+ description: The archived file, as an attachment
411
+ headers:
412
+ Content-Disposition:
413
+ description: >-
414
+ `attachment; filename="…"`, the renderer's file
415
+ name kept to letters, digits, `.`, `_` and `-`
416
+ schema: { type: string }
417
+ content:
418
+ application/pdf:
419
+ schema: { type: string, format: binary }
420
+ '404':
421
+ description: No invoice with this id (`SUBSCRIPTION_INVOICE_NOT_FOUND`)
422
+ '409':
423
+ description: >-
424
+ The invoice still waits for its document
425
+ (`SUBSCRIPTION_INVOICE_DOCUMENT_NOT_ARCHIVED`), or its
426
+ archived file does not match the recorded checksum and
427
+ is not handed out (`SUBSCRIPTION_INVOICE_DOCUMENT_MISMATCH`)
428
+
390
429
  /tenants/{slug}/subscriber:
391
430
  parameters:
392
431
  - $ref: '#/components/parameters/TenantSlug'
@@ -1354,6 +1393,129 @@ paths:
1354
1393
  type: array
1355
1394
  items: { $ref: '#/components/schemas/BundleVersionRetirement' }
1356
1395
 
1396
+ # ── Feature withdrawals ──
1397
+ #
1398
+ # Served where the manifest carries the capability `features.withdraw`: where the
1399
+ # installation can list everybody a withdrawal reaches. A feature withdrawn for a reason
1400
+ # outside the platform — an external service stopped, a law changed — is granted to
1401
+ # nobody from the date named until the withdrawal is lifted. The subscriptions holding
1402
+ # it when it is announced are told at once; each line is reduced by the amount named
1403
+ # for its plan or add-on and rhythm, and each may end at once while it is withdrawn.
1404
+
1405
+ /feature-withdrawals:
1406
+ get:
1407
+ tags: [catalog]
1408
+ summary: Every withdrawal, the most recently announced first
1409
+ responses:
1410
+ '200':
1411
+ description: List, each with how many it reached, told and ended at once
1412
+ content:
1413
+ application/json:
1414
+ schema:
1415
+ type: array
1416
+ items: { $ref: '#/components/schemas/FeatureWithdrawal' }
1417
+ post:
1418
+ tags: [catalog]
1419
+ summary: Withdraw a feature from every subscription that holds it
1420
+ description: >-
1421
+ The withdrawal and one notice per subscription are recorded in one transaction,
1422
+ then handed to the application's `SubscriptionNoticePort` as `feature-withdrawn`;
1423
+ a notice that cannot be sent now is sent by the next run. The reductions are
1424
+ written into each subscription's contract. Audited as FEATURE_WITHDRAW.
1425
+ x-mfa-required: true
1426
+ requestBody:
1427
+ required: true
1428
+ content:
1429
+ application/json:
1430
+ schema: { $ref: '#/components/schemas/FeatureWithdrawalAnnouncement' }
1431
+ responses:
1432
+ '201':
1433
+ description: Announced
1434
+ content:
1435
+ application/json:
1436
+ schema: { $ref: '#/components/schemas/FeatureWithdrawalAnnounced' }
1437
+ '404':
1438
+ description: >-
1439
+ Not offered here: the stores cannot list everybody a withdrawal reaches
1440
+ (`FEATURE_WITHDRAWAL_UNAVAILABLE`)
1441
+ '409':
1442
+ description: >-
1443
+ The subscriptions it reaches are no longer the ones named
1444
+ (`FEATURE_WITHDRAWAL_PREVIEW_CHANGED`, with the preview as it stands), or
1445
+ another withdrawal of the feature landed first (`FEATURE_WITHDRAWAL_OPEN`)
1446
+ '422':
1447
+ description: >-
1448
+ An unknown feature (`FEATURE_WITHDRAWAL_FEATURE_UNKNOWN`), a date already
1449
+ past (`FEATURE_WITHDRAWAL_DATE_IN_PAST`), a reduction that names nothing it
1450
+ reaches, is named twice or exceeds the lowest price it reduces
1451
+ (`FEATURE_WITHDRAWAL_REDUCTION_*`), or the first blocker the preview reports
1452
+
1453
+ /feature-withdrawals/preview:
1454
+ get:
1455
+ tags: [catalog]
1456
+ summary: What withdrawing the feature from the date would do now
1457
+ parameters:
1458
+ - name: featureKey
1459
+ in: query
1460
+ required: true
1461
+ schema: { type: string }
1462
+ - name: effectiveFrom
1463
+ in: query
1464
+ required: false
1465
+ description: ISO-8601; now where left out
1466
+ schema: { type: string, format: date-time }
1467
+ responses:
1468
+ '200':
1469
+ description: >-
1470
+ Whom it reaches with which lines, whom it does not and why, the plans and
1471
+ add-ons a reduction may be named for, and every blocker
1472
+ content:
1473
+ application/json:
1474
+ schema: { $ref: '#/components/schemas/FeatureWithdrawalPreview' }
1475
+ '404':
1476
+ description: Not offered here (`FEATURE_WITHDRAWAL_UNAVAILABLE`)
1477
+ '422':
1478
+ description: >-
1479
+ An unknown feature (`FEATURE_WITHDRAWAL_FEATURE_UNKNOWN`), or a date already
1480
+ past (`FEATURE_WITHDRAWAL_DATE_IN_PAST`)
1481
+
1482
+ /feature-withdrawals/{id}/lift:
1483
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1484
+ post:
1485
+ tags: [catalog]
1486
+ summary: Lift a withdrawal from a date
1487
+ description: >-
1488
+ From the date the feature is granted again, and the reduction and the right to end
1489
+ at once end; every subscription it reached that still runs is told as
1490
+ `feature-withdrawal-lifted`. Audited as FEATURE_WITHDRAWAL_LIFT.
1491
+ x-mfa-required: true
1492
+ requestBody:
1493
+ required: true
1494
+ content:
1495
+ application/json:
1496
+ schema:
1497
+ type: object
1498
+ properties:
1499
+ liftedFrom:
1500
+ type: string
1501
+ format: date-time
1502
+ description: ISO-8601; now where left out
1503
+ responses:
1504
+ '201':
1505
+ description: Lifted
1506
+ content:
1507
+ application/json:
1508
+ schema: { $ref: '#/components/schemas/FeatureWithdrawalLifted' }
1509
+ '404':
1510
+ description: No withdrawal has that id (`FEATURE_WITHDRAWAL_NOT_FOUND`)
1511
+ '409':
1512
+ description: >-
1513
+ Lifted already (`FEATURE_WITHDRAWAL_ALREADY_LIFTED`, with its date), or nobody
1514
+ it reaches told of it yet, telling them having failed just now
1515
+ (`FEATURE_WITHDRAWAL_NOT_TOLD_YET`)
1516
+ '422':
1517
+ description: A date already past (`FEATURE_WITHDRAWAL_LIFT_IN_PAST`)
1518
+
1357
1519
  # ── Bundles ──
1358
1520
  /catalog/bundles:
1359
1521
  get:
@@ -2384,6 +2546,182 @@ components:
2384
2546
  told: { type: integer, description: 'Notices sent at once' }
2385
2547
  failed: { type: integer, description: 'Notices the next run sends' }
2386
2548
 
2549
+ FeatureWithdrawalReduction:
2550
+ type: object
2551
+ required: [kind, key, billingCycle, amountNet]
2552
+ properties:
2553
+ kind: { type: string, enum: [plan, bundle] }
2554
+ key: { type: string, description: 'The plan key, or the add-on key' }
2555
+ billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
2556
+ amountNet:
2557
+ type: number
2558
+ description: 'Net, per whole period of the rhythm, to the cent'
2559
+
2560
+ FeatureWithdrawalAnnouncement:
2561
+ type: object
2562
+ required: [featureKey, reason, reductions, subscriptionIds]
2563
+ properties:
2564
+ featureKey: { type: string }
2565
+ reason: { type: string, description: 'Shown to the subscribers as written' }
2566
+ effectiveFrom:
2567
+ { type: string, format: date-time, description: 'Now where left out' }
2568
+ reductions:
2569
+ type: array
2570
+ items: { $ref: '#/components/schemas/FeatureWithdrawalReduction' }
2571
+ subscriptionIds:
2572
+ type: array
2573
+ description: 'The subscriptions the operator was shown in the preview'
2574
+ items: { type: string }
2575
+
2576
+ FeatureWithdrawalRow:
2577
+ type: object
2578
+ required:
2579
+ [
2580
+ id,
2581
+ featureKey,
2582
+ reason,
2583
+ effectiveFrom,
2584
+ liftedFrom,
2585
+ reductions,
2586
+ announcedAt,
2587
+ announcedBy,
2588
+ liftedAt,
2589
+ liftedBy,
2590
+ toldAt,
2591
+ ]
2592
+ properties:
2593
+ id: { type: string }
2594
+ featureKey: { type: string }
2595
+ reason: { type: string }
2596
+ effectiveFrom: { type: string, format: date-time }
2597
+ liftedFrom: { type: [string, 'null'], format: date-time }
2598
+ reductions:
2599
+ type: array
2600
+ items: { $ref: '#/components/schemas/FeatureWithdrawalReduction' }
2601
+ announcedAt: { type: string, format: date-time }
2602
+ announcedBy:
2603
+ { type: string, description: 'An actor tag, as the audit log writes it' }
2604
+ liftedAt: { type: [string, 'null'], format: date-time }
2605
+ liftedBy: { type: [string, 'null'] }
2606
+ toldAt:
2607
+ type: [string, 'null']
2608
+ format: date-time
2609
+ description: >-
2610
+ When the subscriptions it reaches were told; null until then — the
2611
+ quarter-hourly run tells a withdrawal still null.
2612
+
2613
+ FeatureWithdrawal:
2614
+ allOf:
2615
+ - $ref: '#/components/schemas/FeatureWithdrawalRow'
2616
+ - type: object
2617
+ required: [featureLabel, progress]
2618
+ properties:
2619
+ featureLabel: { type: string }
2620
+ progress:
2621
+ type: object
2622
+ required: [reached, told, endedAtOnce]
2623
+ properties:
2624
+ reached: { type: integer, description: 'Subscriptions it reached' }
2625
+ told:
2626
+ type: integer
2627
+ description: 'Of those, the ones whose notice reached somebody'
2628
+ endedAtOnce:
2629
+ type: integer
2630
+ description: 'Subscriptions and bookings ended at once under it'
2631
+
2632
+ FeatureWithdrawalAnnounced:
2633
+ type: object
2634
+ required: [withdrawal, told, failed]
2635
+ properties:
2636
+ withdrawal: { $ref: '#/components/schemas/FeatureWithdrawalRow' }
2637
+ told: { type: integer, description: 'Notices sent at once' }
2638
+ failed: { type: integer, description: 'Notices the next run sends' }
2639
+
2640
+ FeatureWithdrawalLifted:
2641
+ type: object
2642
+ required: [withdrawal, told, failed]
2643
+ properties:
2644
+ withdrawal: { $ref: '#/components/schemas/FeatureWithdrawalRow' }
2645
+ told: { type: integer, description: 'Notices sent at once' }
2646
+ failed: { type: integer, description: 'Notices the next run sends' }
2647
+
2648
+ FeatureWithdrawalPreview:
2649
+ type: object
2650
+ required: [feature, effectiveFrom, asOf, reached, skipped, targets, blockers]
2651
+ properties:
2652
+ feature:
2653
+ type: object
2654
+ required: [key, label]
2655
+ properties:
2656
+ key: { type: string }
2657
+ label: { type: string }
2658
+ effectiveFrom: { type: string, description: 'ISO-8601' }
2659
+ asOf: { type: string, description: 'ISO-8601: the moment it is computed for' }
2660
+ reached:
2661
+ type: array
2662
+ items:
2663
+ type: object
2664
+ required: [tenantId, subscriptionId, status, lines, specialTerms]
2665
+ properties:
2666
+ tenantId: { type: string }
2667
+ subscriptionId: { type: string }
2668
+ status: { type: string }
2669
+ lines:
2670
+ type: array
2671
+ items:
2672
+ type: object
2673
+ required:
2674
+ [
2675
+ line,
2676
+ key,
2677
+ label,
2678
+ subscriptionBundleId,
2679
+ billingCycle,
2680
+ priceNet,
2681
+ ]
2682
+ properties:
2683
+ line: { type: string, enum: [plan, bundle] }
2684
+ key: { type: string }
2685
+ label: { type: string }
2686
+ subscriptionBundleId: { type: [string, 'null'] }
2687
+ billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
2688
+ priceNet: { type: [number, 'null'] }
2689
+ specialTerms:
2690
+ type: boolean
2691
+ description: 'Holds the feature through special terms, and no line'
2692
+ skipped:
2693
+ type: array
2694
+ items:
2695
+ type: object
2696
+ required: [tenantId, subscriptionId, reason]
2697
+ properties:
2698
+ tenantId: { type: string }
2699
+ subscriptionId: { type: string }
2700
+ reason: { type: string, enum: [ends-before] }
2701
+ targets:
2702
+ type: array
2703
+ description: 'Each plan and add-on in a rhythm a reduction may be named for'
2704
+ items:
2705
+ type: object
2706
+ required: [kind, key, label, billingCycle, lines, lowestPriceNet]
2707
+ properties:
2708
+ kind: { type: string, enum: [plan, bundle] }
2709
+ key: { type: string }
2710
+ label: { type: string }
2711
+ billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
2712
+ lines: { type: integer }
2713
+ lowestPriceNet: { type: [number, 'null'] }
2714
+ blockers:
2715
+ type: array
2716
+ description: 'Empty where it may be announced as it stands'
2717
+ items:
2718
+ type: object
2719
+ required: [code, message, params]
2720
+ properties:
2721
+ code: { type: string, description: 'FEATURE_WITHDRAWAL_*' }
2722
+ message: { type: string }
2723
+ params: { type: object }
2724
+
2387
2725
  RetiredBundleVersionRef:
2388
2726
  type: object
2389
2727
  required: [bundleVersionId, bundleKey, version]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.25",
3
+ "version": "1.0.0-rc.26",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -73,6 +73,7 @@ model SubscriptionContract {
73
73
 
74
74
  lineItems ContractLineItem[]
75
75
  ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
76
+ invoices SubscriptionInvoice[] // see 20-subscription-invoice.prisma
76
77
 
77
78
  createdAt DateTime @default(now())
78
79
  updatedAt DateTime @updatedAt
@@ -131,6 +132,7 @@ model ContractLineItem {
131
132
 
132
133
  contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
133
134
  ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
135
+ invoiceLines SubscriptionInvoiceLine[] // see 20-subscription-invoice.prisma
134
136
 
135
137
  @@index([contractId, kind])
136
138
  @@index([sourceVersionId])
@@ -75,6 +75,7 @@ model Subscriber {
75
75
  paymentMethods SubscriberPaymentMethod[] // see 14-payments.prisma
76
76
  paymentMethodSetups SubscriberPaymentMethodSetup[]
77
77
  ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
78
+ invoices SubscriptionInvoice[] // see 20-subscription-invoice.prisma
78
79
 
79
80
  @@map("subscribers")
80
81
  }
@@ -57,6 +57,7 @@ model SubscriberLedgerEntry {
57
57
  subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
58
58
  contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Restrict)
59
59
  contractLineItem ContractLineItem @relation(fields: [contractLineItemId], references: [id], onDelete: Restrict)
60
+ invoiceLine SubscriptionInvoiceLine? // see 20-subscription-invoice.prisma
60
61
 
61
62
  @@unique([subscriptionId, source, sourceRef, periodStart, origin])
62
63
  @@index([subscriberId, bookedAt])
@@ -0,0 +1,145 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriptionInvoice
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // The invoices issued from the charge journal (15-subscriber-ledger.prisma):
8
+ // the charges a billing period opens with on one invoice, a charge that arises
9
+ // later on an invoice of its own. An invoice copies everything it states when
10
+ // it is issued — the issuer, the subscriber, the tax treatment the adapter
11
+ // decided, the tax per rate — so a later change leaves it as it was. Nothing
12
+ // updates or deletes an invoice; a correction is a cancellation invoice and its
13
+ // replacement.
14
+ //
15
+ // A line invoices one charge, and a charge stands on one line: the unique
16
+ // `chargeId` is what makes a second run issuing the same charges write
17
+ // nothing, however many run at the same time.
18
+ //
19
+ // The number range is the table `subscription_invoice_numbers`, one row per
20
+ // year holding the last number drawn. An invoice draws its number by raising
21
+ // that row in the transaction that writes the invoice, so an invoice that
22
+ // fails to be written leaves no gap, and two at the same time wait for each
23
+ // other rather than drawing the same number. One installation is the range of
24
+ // one issuer; the number is `<prefix>-<year>-<sequence>`.
25
+ //
26
+ // The days an invoice states — its issue date, its due date, the period its
27
+ // charges cover — are calendar days in the installation's time zone, kept as
28
+ // `date` columns; `issuedAt` is the instant it was issued.
29
+ //
30
+ // `origin` and `source` on a line are text holding the charge's closed sets,
31
+ // checked when a row is read (`toSubscriptionInvoiceRecord` in @saasicat/core).
32
+ //
33
+ // Optional: adopt it, all four models together, where
34
+ // `tenantBilling.chargeJournal.invoices` is configured, which needs the charge
35
+ // journal.
36
+ //
37
+ // Contract: @saasicat/core (SubscriptionInvoiceRepository).
38
+
39
+ model SubscriptionInvoice {
40
+ id String @id @default(uuid())
41
+ number String @unique
42
+ numberPrefix String
43
+ numberYear Int
44
+ numberSequence Int
45
+
46
+ tenantId String
47
+ subscriberId String
48
+ subscriptionId String
49
+ contractId String
50
+
51
+ issuedAt DateTime
52
+ issueDate DateTime @db.Date
53
+ dueDate DateTime @db.Date
54
+ servicePeriodFrom DateTime @db.Date
55
+ servicePeriodUntil DateTime @db.Date
56
+
57
+ currency String
58
+ // The parties as the invoice names them, copied when it was issued.
59
+ issuerParty Json
60
+ subscriberParty Json
61
+ // The footer lines config/saas.yaml named on the issue date (SC-PRIC-077).
62
+ footer Json @default("[]")
63
+ // The adapter's treatment, with its name and version, and the tax per rate.
64
+ taxTreatment Json
65
+ taxes Json
66
+ totalNet Decimal @db.Decimal(12, 2)
67
+ totalTax Decimal @db.Decimal(12, 2)
68
+ totalGross Decimal @db.Decimal(12, 2)
69
+
70
+ createdAt DateTime @default(now())
71
+
72
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
73
+ contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Restrict)
74
+ lines SubscriptionInvoiceLine[]
75
+ document SubscriptionInvoiceDocument?
76
+ archive SubscriptionInvoiceArchive?
77
+
78
+ @@unique([numberYear, numberSequence])
79
+ @@index([subscriberId, issuedAt])
80
+ @@index([subscriptionId])
81
+ @@map("subscription_invoices")
82
+ }
83
+
84
+ model SubscriptionInvoiceLine {
85
+ id String @id @default(uuid())
86
+ invoiceId String
87
+ position Int
88
+ chargeId String @unique
89
+ contractLineItemId String
90
+
91
+ title String
92
+ origin String
93
+ source String
94
+
95
+ periodFrom DateTime @db.Date
96
+ periodUntil DateTime @db.Date
97
+
98
+ amountNet Decimal @db.Decimal(10, 2)
99
+ taxRate Decimal @db.Decimal(5, 2)
100
+
101
+ invoice SubscriptionInvoice @relation(fields: [invoiceId], references: [id], onDelete: Restrict)
102
+ charge SubscriberLedgerEntry @relation(fields: [chargeId], references: [id], onDelete: Restrict)
103
+ contractLineItem ContractLineItem @relation(fields: [contractLineItemId], references: [id], onDelete: Restrict)
104
+
105
+ @@unique([invoiceId, position])
106
+ @@index([contractLineItemId])
107
+ @@map("subscription_invoice_lines")
108
+ }
109
+
110
+ // Where an invoice's document stands. The checksum is recorded once, before
111
+ // the file is archived, and every later download is checked against it; the
112
+ // archive's confirmation and the delivery are set once each. A rendering with
113
+ // another checksum is refused rather than recorded over the first. A run claims
114
+ // the mail before it sends it, so of several instances one does.
115
+ model SubscriptionInvoiceDocument {
116
+ invoiceId String @id
117
+ // SHA-256 of the rendered file, 64 lower-case hex digits.
118
+ checksum String
119
+ mediaType String
120
+ fileName String
121
+ // In bytes.
122
+ size Int
123
+ renderedAt DateTime
124
+ // Null while the invoice waits for its document.
125
+ archivedAt DateTime?
126
+ // When the run mailing it took it on; null while no run holds it.
127
+ deliveryClaimedAt DateTime?
128
+ // Null until the invoice is handed to the mail.
129
+ deliveredAt DateTime?
130
+ deliveredTo String?
131
+
132
+ invoice SubscriptionInvoice @relation(fields: [invoiceId], references: [id], onDelete: Restrict)
133
+
134
+ @@index([archivedAt])
135
+ @@map("subscription_invoice_documents")
136
+ }
137
+
138
+ model SubscriptionInvoiceNumber {
139
+ // The year the numbers count in; the range restarts at 1 each year.
140
+ year Int @id
141
+ // The last number drawn in that year.
142
+ last Int
143
+
144
+ @@map("subscription_invoice_numbers")
145
+ }
@@ -0,0 +1,56 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: FeatureWithdrawal
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per feature withdrawn for a reason outside the platform: an external
8
+ // service the feature depends on stopped, or a law took it away or changed it.
9
+ // From `effectiveFrom` the platform grants the feature to nobody, until the
10
+ // operator lifts the withdrawal from `liftedFrom`. Kept as evidence of what was
11
+ // announced, why, when and by whom, and of the reductions the operator named.
12
+ //
13
+ // What it means for each subscription it reaches — which lines, what each is
14
+ // reduced by — is a `subscription_notices` row of kind feature-withdrawn whose
15
+ // subject is this row's id. Those are written once this row is: who it reaches
16
+ // is read after it is recorded, so a change concluded while it was being
17
+ // announced is reached, and `toldAt` is set in the transaction that writes
18
+ // them. A row whose `toldAt` is still null is told by the next run.
19
+ //
20
+ // `reductions` is a JSON list of `{ kind, key, billingCycle, amountNet }`: the
21
+ // net amount a plan or an add-on, by its key, is reduced by for one whole
22
+ // period of that rhythm without the feature.
23
+ //
24
+ // At most ONE withdrawal of a feature is not lifted at a time. Prisma cannot
25
+ // express a partial unique index; the one that holds it is in
26
+ // sql/constraints.postgres.sql, and it is what makes two operators announcing
27
+ // at the same moment land one withdrawal rather than two.
28
+ //
29
+ // Optional: adopt it where an operator may withdraw features. Without it,
30
+ // withdrawing a feature is off.
31
+ //
32
+ // Contract: @saasicat/core (FeatureWithdrawalRepository).
33
+
34
+ model FeatureWithdrawal {
35
+ id String @id @default(uuid())
36
+ featureKey String
37
+ // Why, in the operator's words, shown to the subscribers as written.
38
+ reason String
39
+
40
+ effectiveFrom DateTime
41
+ // Null until the operator lifts it.
42
+ liftedFrom DateTime?
43
+
44
+ reductions Json @default("[]")
45
+
46
+ announcedAt DateTime @default(now())
47
+ // Who announced it, and who lifted it — actor tags, as the audit log writes them.
48
+ announcedBy String
49
+ liftedAt DateTime?
50
+ liftedBy String?
51
+ // When the subscriptions it reaches were told; null until then.
52
+ toldAt DateTime?
53
+
54
+ @@index([featureKey])
55
+ @@map("feature_withdrawals")
56
+ }
@@ -0,0 +1,34 @@
1
+ // =============================================================================
2
+ // SaaSiCat platform Prisma fragment: SubscriptionInvoiceArchive
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // The database archive of the invoices' documents (20-subscription-invoice.prisma):
8
+ // one file per invoice, under its invoice, with the checksum recorded when it
9
+ // was rendered. A row is written once and never changed: the migration
10
+ // `1.0-an-invoice-document-is-archived.postgres.sql` installs a trigger that
11
+ // refuses every UPDATE and DELETE, whoever asks. Keep it, with its database
12
+ // backups, as long as the law asks — in Germany 8 years from the end of the
13
+ // calendar year an invoice was issued in — and in the European Union.
14
+ //
15
+ // Optional: adopt it where the invoices' documents are kept in the database,
16
+ // as `persistence.entitlement.subscriptionInvoiceArchive` keeps them. An
17
+ // installation that archives elsewhere, such as object storage with a
18
+ // retention lock, binds its own `InvoiceArchivePort` instead.
19
+ //
20
+ // Contract: @saasicat/core (InvoiceArchivePort).
21
+
22
+ model SubscriptionInvoiceArchive {
23
+ invoiceId String @id
24
+ // SHA-256 of `content`, 64 lower-case hex digits.
25
+ checksum String
26
+ mediaType String
27
+ fileName String
28
+ content Bytes
29
+ archivedAt DateTime
30
+
31
+ invoice SubscriptionInvoice @relation(fields: [invoiceId], references: [id], onDelete: Restrict)
32
+
33
+ @@map("subscription_invoice_archive")
34
+ }