@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.
- package/admin-api.openapi.yaml +339 -1
- package/package.json +1 -1
- package/prisma-fragments/08-subscription-contract.prisma +2 -0
- package/prisma-fragments/13-subscriber.prisma +1 -0
- package/prisma-fragments/15-subscriber-ledger.prisma +1 -0
- package/prisma-fragments/20-subscription-invoice.prisma +145 -0
- package/prisma-fragments/21-feature-withdrawal.prisma +56 -0
- package/prisma-fragments/22-subscription-invoice-archive.prisma +34 -0
- package/prisma-fragments/README.md +24 -21
- package/schemas/admin-manifest.schema.json +2 -1
- package/schemas/plan-catalog.schema.json +32 -1
- package/schemas/subscriber-ledger.schema.json +3 -2
- package/sql/1.0-a-correction-carries-its-order.postgres.sql +27 -4
- package/sql/1.0-a-feature-is-withdrawn.postgres.sql +49 -0
- package/sql/1.0-an-invoice-document-is-archived.postgres.sql +123 -0
- package/sql/1.0-an-invoice-is-issued.postgres.sql +171 -0
- package/sql/constraints.postgres.sql +7 -0
- package/sql/reference-schema.postgres.sql +159 -0
package/admin-api.openapi.yaml
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
+
}
|