@bongos/core 1.24.23 → 1.24.25
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/.bongos-core.json +212 -57
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +42 -0
- package/clients/bongos-client/index.cjs +42 -0
- package/clients/bongos-client/index.d.ts +55 -1
- package/clients/bongos-client/index.mjs +42 -0
- package/docs/api/openapi.json +1181 -207
- package/docs/api-reference.md +30 -5
- package/docs/architecture.md +42 -0
- package/docs/copy-inventory.md +18 -8
- package/docs/copy-registry.json +99 -9
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +64 -25
- package/migrations/core_291_payout_nda_tax.sql +75 -0
- package/migrations/core_292_module_sale_payouts.sql +170 -0
- package/migrations/core_293_cash_outs.sql +184 -0
- package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
- package/modules/economy/ledger-check.js +360 -0
- package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
- package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
- package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
- package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/outflow.js +209 -0
- package/modules/economy/pollers/ledger-check.js +87 -0
- package/modules/economy/project-account.js +92 -7
- package/modules/economy/routes/balances.js +79 -0
- package/modules/economy/routes/project-account.js +40 -2
- package/modules/economy/user-balances.js +308 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/hall-ui/public/project-settings.css +18 -0
- package/modules/hall-ui/public/project-settings.html +15 -2
- package/modules/hall-ui/public/project-settings.js +142 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/blockers.js +26 -0
- package/modules/lifecycle/db-claims.js +22 -0
- package/modules/lifecycle/db-ship.js +5 -5
- package/modules/lifecycle/funding-wall.js +253 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +9 -0
- package/modules/lifecycle/routes/pay-type-gates.js +7 -0
- package/modules/lifecycle/routes/project-settings.js +59 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +44 -0
- package/scripts/bongos/government-walk.js +1 -0
- package/scripts/bongos/plain-cards.js +18 -0
- package/scripts/bongos/run-unit-tests.js +28 -0
- package/src/bongos/builder-payouts.js +6 -1
- package/src/bongos/cash-outs.js +399 -0
- package/src/bongos/module-payouts.js +432 -0
- package/src/bongos/package-registry.js +3 -2
- package/src/bongos/payout-approvals.js +5 -2
- package/src/bongos/payout-gates.js +44 -7
- package/src/bongos/project-settings.js +2 -0
- package/src/bongos/routes/core-update.js +11 -3
- package/src/bongos/routes/module-payouts.js +114 -0
- package/src/bongos/routes/my-cash-outs.js +103 -0
- package/src/bongos/routes/payout-gates.js +42 -1
- package/src/bongos/routes.js +6 -0
- package/src/bongos/software-update.js +36 -16
- package/src/module-api.js +1 -1
- package/tests/builder_payouts_db.mjs +1 -0
- package/tests/cash_outs.mjs +96 -0
- package/tests/cash_outs_db.mjs +205 -0
- package/tests/funded_project_pays_proof_db.mjs +181 -0
- package/tests/funding_wall.mjs +205 -0
- package/tests/funding_wall_db.mjs +174 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
- package/tests/ledger_check.mjs +189 -0
- package/tests/ledger_check_db.mjs +119 -0
- package/tests/module_payouts.mjs +295 -0
- package/tests/module_payouts_db.mjs +140 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/payout_gates.mjs +67 -6
- package/tests/payout_gates_db.mjs +23 -1
- package/tests/project_account_db.mjs +5 -2
- package/tests/project_outflow.mjs +103 -0
- package/tests/project_outflow_db.mjs +100 -0
- package/tests/software_update.mjs +61 -3
- package/tests/user_balances.mjs +241 -0
- package/tests/user_balances_db.mjs +137 -0
package/docs/page-readings.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"adr": "0341 D3",
|
|
6
6
|
"reread": "node scripts/bongos/page-reader.js --stale",
|
|
7
7
|
"how": ["Generated by node scripts/bongos/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
|
|
8
|
-
"counts": {"pages":52,"lines":
|
|
8
|
+
"counts": {"pages":52,"lines":5651,"placed":1762,"shared":461,"unplaced":3428,"omitted_data":3797},
|
|
9
9
|
"pages": [
|
|
10
10
|
{
|
|
11
11
|
"id": "builders:ai-studio",
|
|
@@ -599,7 +599,7 @@
|
|
|
599
599
|
"surface": "builders",
|
|
600
600
|
"title": "Deploy",
|
|
601
601
|
"states_read": ["work","out"],
|
|
602
|
-
"files_hash": "
|
|
602
|
+
"files_hash": "d6dcbe183e4963f5",
|
|
603
603
|
"reading_hash": "b9b3d3cdbfae00be",
|
|
604
604
|
"counts": {"lines":17,"placed":5,"shared":8,"unplaced":4},
|
|
605
605
|
"omitted": {"data":3},
|
|
@@ -1118,7 +1118,7 @@
|
|
|
1118
1118
|
"surface": "builders",
|
|
1119
1119
|
"title": "Fleet costs",
|
|
1120
1120
|
"states_read": ["ledger","out"],
|
|
1121
|
-
"files_hash": "
|
|
1121
|
+
"files_hash": "734f72fc06bbad36",
|
|
1122
1122
|
"reading_hash": "382e521aaa529c82",
|
|
1123
1123
|
"counts": {"lines":32,"placed":9,"shared":8,"unplaced":15},
|
|
1124
1124
|
"omitted": {"data":13},
|
|
@@ -2768,10 +2768,10 @@
|
|
|
2768
2768
|
"surface": "builders",
|
|
2769
2769
|
"title": "Project settings",
|
|
2770
2770
|
"states_read": ["default"],
|
|
2771
|
-
"files_hash": "
|
|
2772
|
-
"reading_hash": "
|
|
2773
|
-
"counts": {"lines":
|
|
2774
|
-
"omitted": {"data":
|
|
2771
|
+
"files_hash": "a182b780fad8350e",
|
|
2772
|
+
"reading_hash": "fb1de73bb67c5c0d",
|
|
2773
|
+
"counts": {"lines":77,"placed":32,"shared":8,"unplaced":37},
|
|
2774
|
+
"omitted": {"data":37},
|
|
2775
2775
|
"notes": [],
|
|
2776
2776
|
"lines": [
|
|
2777
2777
|
{"key":"L0001","section":"skip link","text":"Skip to content","placement":"shared","file":"modules/hall-ui/public/shell.js","line":881,"string_id":"87cee78ca134"},
|
|
@@ -2791,27 +2791,66 @@
|
|
|
2791
2791
|
{"key":"L0015","section":"The fundamentals","text":"days ({…}–{…})","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":115,"string_id":"0e2bef756977"},
|
|
2792
2792
|
{"key":"L0016","section":"The fundamentals","text":"offered","placement":"unplaced","occurrences":2},
|
|
2793
2793
|
{"key":"L0017","section":"The fundamentals","text":"New category label","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":193,"string_id":"87f09d983f6c","attr":"placeholder"},
|
|
2794
|
-
{"key":"L0018","section":"The fundamentals","text":"Add","placement":"placed","file":"modules/hall-ui/public/ai-studio.js","line":
|
|
2794
|
+
{"key":"L0018","section":"The fundamentals","text":"Add","placement":"placed","file":"modules/hall-ui/public/ai-studio.js","line":567,"string_id":"e4da002ae135"},
|
|
2795
2795
|
{"key":"L0019","section":"The fundamentals","text":"committed config","placement":"unplaced"},
|
|
2796
2796
|
{"key":"L0020","section":"The fundamentals","text":"not yet real","placement":"unplaced"},
|
|
2797
2797
|
{"key":"L0021","section":"The fundamentals","text":"not available ({…})","placement":"unplaced"},
|
|
2798
|
-
{"key":"L0022","section":"
|
|
2799
|
-
{"key":"L0023","section":"
|
|
2800
|
-
{"key":"L0024","section":"
|
|
2801
|
-
{"key":"L0025","section":"
|
|
2802
|
-
{"key":"L0026","section":"
|
|
2803
|
-
{"key":"L0027","section":"
|
|
2804
|
-
{"key":"L0028","section":"
|
|
2805
|
-
{"key":"L0029","section":"
|
|
2806
|
-
{"key":"L0030","section":"
|
|
2807
|
-
{"key":"L0031","section":"
|
|
2808
|
-
{"key":"L0032","section":"
|
|
2809
|
-
{"key":"L0033","section":"
|
|
2810
|
-
{"key":"L0034","section":"
|
|
2811
|
-
{"key":"L0035","section":"
|
|
2812
|
-
{"key":"L0036","section":"
|
|
2813
|
-
{"key":"L0037","section":"
|
|
2814
|
-
{"key":"L0038","section":"
|
|
2798
|
+
{"key":"L0022","section":"Where the money went","text":"Where the money went","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":57,"string_id":"197969e4126a"},
|
|
2799
|
+
{"key":"L0023","section":"Where the money went","text":"What this project has spent, what each builder has been paid, and what it has promised but not paid yet. A balance only means something after those promises.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":58,"string_id":"b857cc458c6f"},
|
|
2800
|
+
{"key":"L0024","section":"Where the money went","text":"Balance","placement":"unplaced"},
|
|
2801
|
+
{"key":"L0025","section":"Where the money went","text":"on our record","placement":"unplaced"},
|
|
2802
|
+
{"key":"L0026","section":"Where the money went","text":"Owed but not paid","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":477,"string_id":"eae3165eaaa0"},
|
|
2803
|
+
{"key":"L0027","section":"Where the money went","text":"2 payments","placement":"unplaced"},
|
|
2804
|
+
{"key":"L0028","section":"Where the money went","text":"Approved, not started","placement":"unplaced"},
|
|
2805
|
+
{"key":"L0029","section":"Where the money went","text":"1 approval","placement":"unplaced"},
|
|
2806
|
+
{"key":"L0030","section":"Where the money went","text":"{…} of {…}","placement":"placed","file":"modules/hall-ui/public/sky-panel.js","line":120,"string_id":"73c1372b43d9"},
|
|
2807
|
+
{"key":"L0031","section":"Where the money went","text":"what is really free","placement":"unplaced"},
|
|
2808
|
+
{"key":"L0032","section":"Owed but not paid","text":"Owed but not paid","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":477,"string_id":"eae3165eaaa0"},
|
|
2809
|
+
{"key":"L0033","section":"Owed but not paid","text":"Builder","placement":"unplaced"},
|
|
2810
|
+
{"key":"L0034","section":"Owed but not paid","text":"Task","placement":"placed","file":"modules/hall-ui/public/task.js","line":512,"string_id":"eff70e59d12e"},
|
|
2811
|
+
{"key":"L0035","section":"Owed but not paid","text":"Amount","placement":"unplaced"},
|
|
2812
|
+
{"key":"L0036","section":"Owed but not paid","text":"Owed since","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":477,"string_id":"0ba40083f03e"},
|
|
2813
|
+
{"key":"L0037","section":"Owed but not paid","text":"Last try","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":477,"string_id":"165c5f26ef60"},
|
|
2814
|
+
{"key":"L0038","section":"Owed but not paid","text":"Waiting — no payment processor connected yet","placement":"unplaced"},
|
|
2815
|
+
{"key":"L0039","section":"Owed but not paid","text":"Did not go through — {…}","placement":"unplaced"},
|
|
2816
|
+
{"key":"L0040","section":"By builder","text":"By builder","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":493,"string_id":"fa9356114e6b"},
|
|
2817
|
+
{"key":"L0041","section":"By builder","text":"Builder","placement":"unplaced"},
|
|
2818
|
+
{"key":"L0042","section":"By builder","text":"Spent","placement":"unplaced"},
|
|
2819
|
+
{"key":"L0043","section":"By builder","text":"Paid","placement":"unplaced"},
|
|
2820
|
+
{"key":"L0044","section":"By builder","text":"Owed","placement":"unplaced"},
|
|
2821
|
+
{"key":"L0045","section":"By builder","text":"Approved","placement":"unplaced"},
|
|
2822
|
+
{"key":"L0046","section":"By builder","text":"Spent is the recorded cost of each builder’s sessions (API, compute). Paid, owed and approved are payouts.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":502,"string_id":"a553a7f1f108"},
|
|
2823
|
+
{"key":"L0047","section":"Money out, by month","text":"Money out, by month","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":513,"string_id":"8a30ab62ad1d"},
|
|
2824
|
+
{"key":"L0048","section":"Money out, by month","text":"Dec 2025","placement":"unplaced"},
|
|
2825
|
+
{"key":"L0049","section":"Money out, by month","text":"Jan 2026","placement":"unplaced"},
|
|
2826
|
+
{"key":"L0050","section":"Money out, by month","text":"Feb 2026","placement":"unplaced"},
|
|
2827
|
+
{"key":"L0051","section":"Money out, by month","text":"Mar 2026","placement":"unplaced"},
|
|
2828
|
+
{"key":"L0052","section":"Money out, by month","text":"Apr 2026","placement":"unplaced"},
|
|
2829
|
+
{"key":"L0053","section":"Money out, by month","text":"May 2026","placement":"unplaced"},
|
|
2830
|
+
{"key":"L0054","section":"Money out, by month","text":"Jun 2026","placement":"unplaced"},
|
|
2831
|
+
{"key":"L0055","section":"Money out, by month","text":"Jul 2026","placement":"unplaced"},
|
|
2832
|
+
{"key":"L0056","section":"Money out, by month","text":"Aug 2026","placement":"unplaced"},
|
|
2833
|
+
{"key":"L0057","section":"Money out, by month","text":"Sep 2026","placement":"unplaced"},
|
|
2834
|
+
{"key":"L0058","section":"Money out, by month","text":"Oct 2026","placement":"unplaced"},
|
|
2835
|
+
{"key":"L0059","section":"Money out, by month","text":"Nov 2026","placement":"unplaced"},
|
|
2836
|
+
{"key":"L0060","section":"Money out, by month","text":"Each month is recorded spend ({…} over these {…} months) plus payouts and fees out of the project account. Read from the cost log and the account’s own record.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":513,"string_id":"1f3b826a05d4"},
|
|
2837
|
+
{"key":"L0061","section":"Danger zone","text":"Danger zone","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":69,"string_id":"13b22e48d9fb"},
|
|
2838
|
+
{"key":"L0062","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":412,"string_id":"e97b2d549f10"},
|
|
2839
|
+
{"key":"L0063","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":413,"string_id":"4e261fd15bd5"},
|
|
2840
|
+
{"key":"L0064","section":"Danger zone","text":"Take it offline","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":72,"string_id":"da9da6e866bd"},
|
|
2841
|
+
{"key":"L0065","section":"Danger zone","text":"— stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":72,"string_id":"61ebc5d684fd"},
|
|
2842
|
+
{"key":"L0066","section":"Danger zone","text":"Disconnect from Bongos entirely","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":73,"string_id":"d3efd513694b"},
|
|
2843
|
+
{"key":"L0067","section":"Danger zone","text":"— erases the project’s server, its database and every backup, and releases its name and web address. Your repository on GitHub is kept. This cannot be undone.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":73,"string_id":"d9ad3ee37a88"},
|
|
2844
|
+
{"key":"L0068","section":"footer","text":"Sign out","placement":"shared","file":"modules/hall-ui/public/shell.js","line":520,"string_id":"1883ad7f07fc"},
|
|
2845
|
+
{"key":"L0069","section":"Danger zone","text":"Builders hall · this session","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":670,"string_id":"fd1903586e8e"},
|
|
2846
|
+
{"key":"L0070","section":"What are you doing now?","text":"What are you doing now?","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":671,"string_id":"60853319f290"},
|
|
2847
|
+
{"key":"L0071","section":"What are you doing now?","text":"Pick one. You can switch any time from the top bar.","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":672,"string_id":"4f87d5b17b54"},
|
|
2848
|
+
{"key":"L0072","section":"What are you doing now?","text":"Building","placement":"unplaced"},
|
|
2849
|
+
{"key":"L0073","section":"What are you doing now?","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":3},
|
|
2850
|
+
{"key":"L0074","section":"What are you doing now?","text":"Artistry","placement":"unplaced"},
|
|
2851
|
+
{"key":"L0075","section":"What are you doing now?","text":"Ideating","placement":"unplaced"},
|
|
2852
|
+
{"key":"L0076","section":"What are you doing now?","text":"Turn thoughts into ideas.","placement":"unplaced"},
|
|
2853
|
+
{"key":"L0077","section":"What are you doing now?","text":"Ask me again next session","placement":"placed","file":"modules/hall-ui/public/craft-mode.js","line":675,"string_id":"fa86bf403775"}
|
|
2815
2854
|
]
|
|
2816
2855
|
},
|
|
2817
2856
|
{
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- core_291_payout_nda_tax.sql — contractor pay: an NDA on file and tax details
|
|
2
|
+
-- collected before the first payout (task 1004908, goal 1000092 N4; ADR 0385,
|
|
3
|
+
-- the "Contractor pay" row of the per-flow map).
|
|
4
|
+
--
|
|
5
|
+
-- WHY. The owner's legal framework (cited by Article only) puts two more papers
|
|
6
|
+
-- in front of paying our own contractors: an NDA recorded for the person (the
|
|
7
|
+
-- beta overlay, Art. XI) and identity and tax details collected through the
|
|
8
|
+
-- processor's onboarding. Both join the gates core_284 built: a payout to someone
|
|
9
|
+
-- without them is held, and the hold tells them why (no silent stop).
|
|
10
|
+
--
|
|
11
|
+
-- SHAPE. Two append-only tables (a correction is a new row; the latest row wins),
|
|
12
|
+
-- under core_284's shared refuse-change trigger:
|
|
13
|
+
--
|
|
14
|
+
-- payout_ndas whether a signed NDA is on file for a builder: on_file or
|
|
15
|
+
-- withdrawn, which version, and where the signed copy lives
|
|
16
|
+
-- (a reference such as an e-signature envelope id). The NDA's
|
|
17
|
+
-- own text is never stored here.
|
|
18
|
+
-- payout_tax_details whether the processor's onboarding has collected the
|
|
19
|
+
-- person's identity and tax details: collected or incomplete,
|
|
20
|
+
-- who says so (the processor once one is wired, or a person
|
|
21
|
+
-- who checked), and the processor's onboarding reference.
|
|
22
|
+
-- There is NO column for a tax id, and the reference refuses
|
|
23
|
+
-- anything that looks like one: the processor holds those
|
|
24
|
+
-- details, Bongos only records that they exist.
|
|
25
|
+
--
|
|
26
|
+
-- Neither table records a rate, a schedule or a refusal to take work: they are
|
|
27
|
+
-- independent contractors (Art. IV), and these papers only gate money moving.
|
|
28
|
+
|
|
29
|
+
BEGIN;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS payout_ndas (
|
|
32
|
+
id bigserial PRIMARY KEY,
|
|
33
|
+
builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE RESTRICT,
|
|
34
|
+
status text NOT NULL CHECK (status IN ('on_file', 'withdrawn')),
|
|
35
|
+
nda_version text NOT NULL CHECK (nda_version ~ '^[A-Za-z0-9._-]{1,40}$'),
|
|
36
|
+
reference text CHECK (reference IS NULL OR length(reference) <= 200),
|
|
37
|
+
recorded_by bigint NOT NULL REFERENCES builders(id) ON DELETE RESTRICT,
|
|
38
|
+
recorded_at timestamptz NOT NULL DEFAULT now(),
|
|
39
|
+
CONSTRAINT payout_ndas_not_self CHECK (recorded_by <> builder_id)
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS payout_ndas_builder_idx ON payout_ndas (builder_id, id DESC);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS payout_tax_details (
|
|
44
|
+
id bigserial PRIMARY KEY,
|
|
45
|
+
builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE RESTRICT,
|
|
46
|
+
status text NOT NULL CHECK (status IN ('collected', 'incomplete')),
|
|
47
|
+
collected_by text NOT NULL CHECK (collected_by ~ '^(processor|person):[A-Za-z0-9._-]{1,60}$'),
|
|
48
|
+
reference text CHECK (reference IS NULL OR length(reference) <= 200),
|
|
49
|
+
recorded_by bigint REFERENCES builders(id) ON DELETE RESTRICT,
|
|
50
|
+
recorded_at timestamptz NOT NULL DEFAULT now(),
|
|
51
|
+
CONSTRAINT payout_tax_details_not_self CHECK (recorded_by IS NULL OR recorded_by <> builder_id),
|
|
52
|
+
-- nine digits in a row (dashes and spaces ignored) is the shape of a US tax id
|
|
53
|
+
CONSTRAINT payout_tax_details_no_tax_id CHECK (reference IS NULL OR regexp_replace(reference, '[\s-]', '', 'g') !~ '[0-9]{9}')
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS payout_tax_details_builder_idx ON payout_tax_details (builder_id, id DESC);
|
|
56
|
+
|
|
57
|
+
DO $$
|
|
58
|
+
DECLARE t text;
|
|
59
|
+
BEGIN
|
|
60
|
+
FOREACH t IN ARRAY ARRAY['payout_ndas', 'payout_tax_details'] LOOP
|
|
61
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I', t || '_no_update_delete', t);
|
|
62
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION payout_gates_refuse_change()', t || '_no_update_delete', t);
|
|
63
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I', t || '_no_truncate', t);
|
|
64
|
+
EXECUTE format('CREATE TRIGGER %I BEFORE TRUNCATE ON %I FOR EACH STATEMENT EXECUTE FUNCTION payout_gates_refuse_change()', t || '_no_truncate', t);
|
|
65
|
+
EXECUTE format('REVOKE UPDATE, DELETE, TRUNCATE ON %I FROM PUBLIC', t);
|
|
66
|
+
END LOOP;
|
|
67
|
+
END;
|
|
68
|
+
$$;
|
|
69
|
+
|
|
70
|
+
-- A hold may now name the two new reasons.
|
|
71
|
+
ALTER TABLE payout_freezes DROP CONSTRAINT IF EXISTS payout_freezes_reason_check;
|
|
72
|
+
ALTER TABLE payout_freezes ADD CONSTRAINT payout_freezes_reason_check
|
|
73
|
+
CHECK (reason IN ('sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown', 'nda_missing', 'tax_details_missing', 'manual'));
|
|
74
|
+
|
|
75
|
+
COMMIT;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
-- core_292_module_sale_payouts.sql — a module sale is split by the project's own rule, and
|
|
2
|
+
-- each contributor and the seller is paid their share, exactly once, through the same
|
|
3
|
+
-- approved payout path as a task (task 1004910, goal 1000092 N6; ADR 0388, ADR 0385 D4,
|
|
4
|
+
-- ADR 0397 D7).
|
|
5
|
+
--
|
|
6
|
+
-- FOUR TABLES, ALL APPEND-ONLY (the builder_payouts_append_only() refusal from core_290).
|
|
7
|
+
--
|
|
8
|
+
-- module_split_rules the project's rule for one module: the seller, the seller's
|
|
9
|
+
-- share and the contributors' pool, in basis points of the
|
|
10
|
+
-- price (they add to 10000: the platform fee is paid by the
|
|
11
|
+
-- buyer ON TOP and is never in here, ADR 0388 D1), and the
|
|
12
|
+
-- credit row kinds the pool counts as weight (ADR 0387 D5).
|
|
13
|
+
-- The newest row per module is the rule now; a sale keeps the
|
|
14
|
+
-- rule it sold under (D2), so a rule is never edited.
|
|
15
|
+
-- module_sales one row per sale the processor reported: the price the
|
|
16
|
+
-- seller and contributors share, the platform fee kept apart,
|
|
17
|
+
-- and the rule it sold under. One row per processor sale.
|
|
18
|
+
-- module_sale_shares_owed the owed-but-unpaid record (the M04 idea, keyed by sale
|
|
19
|
+
-- instead of task): one row per (sale, person, role), with the
|
|
20
|
+
-- weight inputs that produced it, so a person can check their
|
|
21
|
+
-- share from inputs they can see (Art. VIII). Written in the
|
|
22
|
+
-- same step as the sale, BEFORE anything is sent.
|
|
23
|
+
-- module_share_attempts every try at paying one share: sent / failed / held, as
|
|
24
|
+
-- builder_payout_attempts. At most one `sent` per share.
|
|
25
|
+
--
|
|
26
|
+
-- ONE APPROVAL PAYS ONE THING. A share is paid only under a person's logged approval for
|
|
27
|
+
-- exactly that person and amount (payout_approvals). module_share_approvals binds one
|
|
28
|
+
-- approval to one share, and a trigger on each side refuses an approval already spent by
|
|
29
|
+
-- the other (a task payout, core_290, or a share here), so no approval pays twice.
|
|
30
|
+
--
|
|
31
|
+
-- Additive (new tables, two triggers on core_290's table, three indexes for the weight
|
|
32
|
+
-- read). Idempotent.
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS module_split_rules (
|
|
35
|
+
id BIGSERIAL PRIMARY KEY,
|
|
36
|
+
module_key TEXT NOT NULL REFERENCES store_modules(module_key),
|
|
37
|
+
seller_id BIGINT NOT NULL REFERENCES builders(id),
|
|
38
|
+
seller_bps INTEGER NOT NULL,
|
|
39
|
+
contributors_bps INTEGER NOT NULL,
|
|
40
|
+
credit_row_kinds TEXT[] NOT NULL,
|
|
41
|
+
set_by BIGINT NOT NULL REFERENCES builders(id),
|
|
42
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
43
|
+
CONSTRAINT module_split_rules_bps_chk CHECK (
|
|
44
|
+
seller_bps BETWEEN 0 AND 10000 AND contributors_bps BETWEEN 0 AND 10000 AND seller_bps + contributors_bps = 10000),
|
|
45
|
+
CONSTRAINT module_split_rules_kinds_chk CHECK (cardinality(credit_row_kinds) BETWEEN 1 AND 10)
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS module_split_rules_current ON module_split_rules (module_key, created_at DESC, id DESC);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS module_sales (
|
|
50
|
+
id BIGSERIAL PRIMARY KEY,
|
|
51
|
+
module_key TEXT NOT NULL REFERENCES store_modules(module_key),
|
|
52
|
+
processor_sale_ref TEXT NOT NULL,
|
|
53
|
+
price_cents BIGINT NOT NULL,
|
|
54
|
+
platform_fee_cents BIGINT NOT NULL DEFAULT 0,
|
|
55
|
+
currency TEXT NOT NULL,
|
|
56
|
+
rule_id BIGINT NOT NULL REFERENCES module_split_rules(id),
|
|
57
|
+
pool_unassigned_cents BIGINT NOT NULL DEFAULT 0,
|
|
58
|
+
recorded_by BIGINT NOT NULL REFERENCES builders(id),
|
|
59
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
60
|
+
CONSTRAINT module_sales_price_chk CHECK (price_cents > 0),
|
|
61
|
+
CONSTRAINT module_sales_fee_chk CHECK (platform_fee_cents >= 0),
|
|
62
|
+
CONSTRAINT module_sales_unassigned_chk CHECK (pool_unassigned_cents >= 0 AND pool_unassigned_cents <= price_cents),
|
|
63
|
+
CONSTRAINT module_sales_currency_chk CHECK (currency ~ '^[A-Z]{3}$'),
|
|
64
|
+
CONSTRAINT module_sales_ref_chk CHECK (length(processor_sale_ref) BETWEEN 1 AND 200)
|
|
65
|
+
);
|
|
66
|
+
CREATE UNIQUE INDEX IF NOT EXISTS module_sales_one_per_ref ON module_sales (processor_sale_ref);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS module_sales_by_time ON module_sales (created_at DESC, id DESC);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS module_sale_shares_owed (
|
|
70
|
+
id BIGSERIAL PRIMARY KEY,
|
|
71
|
+
sale_id BIGINT NOT NULL REFERENCES module_sales(id),
|
|
72
|
+
recipient_id BIGINT NOT NULL REFERENCES builders(id),
|
|
73
|
+
role TEXT NOT NULL,
|
|
74
|
+
amount_cents BIGINT NOT NULL,
|
|
75
|
+
currency TEXT NOT NULL,
|
|
76
|
+
weight BIGINT,
|
|
77
|
+
weight_total BIGINT,
|
|
78
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
79
|
+
CONSTRAINT module_sale_shares_role_chk CHECK (role IN ('seller', 'contributor')),
|
|
80
|
+
CONSTRAINT module_sale_shares_amount_chk CHECK (amount_cents > 0),
|
|
81
|
+
CONSTRAINT module_sale_shares_currency_chk CHECK (currency ~ '^[A-Z]{3}$'),
|
|
82
|
+
-- a contributor's share shows the weight that produced it; a seller's share has none
|
|
83
|
+
CONSTRAINT module_sale_shares_weight_chk CHECK (
|
|
84
|
+
(role = 'seller' AND weight IS NULL AND weight_total IS NULL) OR
|
|
85
|
+
(role = 'contributor' AND weight > 0 AND weight_total >= weight))
|
|
86
|
+
);
|
|
87
|
+
CREATE UNIQUE INDEX IF NOT EXISTS module_sale_shares_one_per_role ON module_sale_shares_owed (sale_id, recipient_id, role);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS module_sale_shares_by_recipient ON module_sale_shares_owed (recipient_id, created_at DESC);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS module_share_approvals (
|
|
91
|
+
share_id BIGINT PRIMARY KEY REFERENCES module_sale_shares_owed(id),
|
|
92
|
+
approval_id BIGINT NOT NULL REFERENCES payout_approvals(id),
|
|
93
|
+
bound_by BIGINT NOT NULL REFERENCES builders(id),
|
|
94
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
95
|
+
);
|
|
96
|
+
CREATE UNIQUE INDEX IF NOT EXISTS module_share_approvals_one_per_approval ON module_share_approvals (approval_id);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS module_share_attempts (
|
|
99
|
+
id BIGSERIAL PRIMARY KEY,
|
|
100
|
+
share_id BIGINT NOT NULL REFERENCES module_sale_shares_owed(id),
|
|
101
|
+
outcome TEXT NOT NULL,
|
|
102
|
+
processor_ref TEXT,
|
|
103
|
+
reason TEXT,
|
|
104
|
+
actor_id BIGINT REFERENCES builders(id),
|
|
105
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
106
|
+
CONSTRAINT module_share_attempts_outcome_chk CHECK (outcome IN ('sent', 'failed', 'held')),
|
|
107
|
+
CONSTRAINT module_share_attempts_reason_len_chk CHECK (reason IS NULL OR length(reason) <= 300),
|
|
108
|
+
CONSTRAINT module_share_attempts_cause_chk CHECK (
|
|
109
|
+
(outcome = 'sent' AND processor_ref IS NOT NULL) OR
|
|
110
|
+
(outcome IN ('failed', 'held') AND reason IS NOT NULL AND length(reason) > 0))
|
|
111
|
+
);
|
|
112
|
+
CREATE UNIQUE INDEX IF NOT EXISTS module_share_attempts_one_sent ON module_share_attempts (share_id) WHERE outcome = 'sent';
|
|
113
|
+
CREATE INDEX IF NOT EXISTS module_share_attempts_by_share ON module_share_attempts (share_id, id DESC);
|
|
114
|
+
|
|
115
|
+
DROP TRIGGER IF EXISTS module_split_rules_no_rewrite ON module_split_rules;
|
|
116
|
+
CREATE TRIGGER module_split_rules_no_rewrite BEFORE UPDATE OR DELETE ON module_split_rules
|
|
117
|
+
FOR EACH ROW EXECUTE FUNCTION builder_payouts_append_only();
|
|
118
|
+
DROP TRIGGER IF EXISTS module_sales_no_rewrite ON module_sales;
|
|
119
|
+
CREATE TRIGGER module_sales_no_rewrite BEFORE UPDATE OR DELETE ON module_sales
|
|
120
|
+
FOR EACH ROW EXECUTE FUNCTION builder_payouts_append_only();
|
|
121
|
+
DROP TRIGGER IF EXISTS module_sale_shares_owed_no_rewrite ON module_sale_shares_owed;
|
|
122
|
+
CREATE TRIGGER module_sale_shares_owed_no_rewrite BEFORE UPDATE OR DELETE ON module_sale_shares_owed
|
|
123
|
+
FOR EACH ROW EXECUTE FUNCTION builder_payouts_append_only();
|
|
124
|
+
DROP TRIGGER IF EXISTS module_share_approvals_no_rewrite ON module_share_approvals;
|
|
125
|
+
CREATE TRIGGER module_share_approvals_no_rewrite BEFORE UPDATE OR DELETE ON module_share_approvals
|
|
126
|
+
FOR EACH ROW EXECUTE FUNCTION builder_payouts_append_only();
|
|
127
|
+
DROP TRIGGER IF EXISTS module_share_attempts_no_rewrite ON module_share_attempts;
|
|
128
|
+
CREATE TRIGGER module_share_attempts_no_rewrite BEFORE UPDATE OR DELETE ON module_share_attempts
|
|
129
|
+
FOR EACH ROW EXECUTE FUNCTION builder_payouts_append_only();
|
|
130
|
+
|
|
131
|
+
-- The contributors' weight read (module-payouts.js WEIGHTS_SQL) runs on every split preview
|
|
132
|
+
-- and every sale. These two make each of its steps an index lookup instead of a scan of an
|
|
133
|
+
-- ever-growing table: a module's shipped tasks, and the sessions that shipped any of them.
|
|
134
|
+
-- (credit_log by task is idx_credit_log_task, core_189.) credit_log by session_id is only
|
|
135
|
+
-- indexed for one reason (uniq_credit_session_token_reward, a partial index a bound reason
|
|
136
|
+
-- list cannot use), so a plain one is added too.
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_shipped_module ON tasks (module_key)
|
|
138
|
+
WHERE module_key IS NOT NULL AND status = 'shipped';
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_session_records_task_ids ON session_records USING gin (task_ids);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_credit_log_session ON credit_log (session_id)
|
|
141
|
+
WHERE session_id IS NOT NULL;
|
|
142
|
+
|
|
143
|
+
-- one approval pays one thing, across a task payout and a module share. Each table's unique
|
|
144
|
+
-- index stops a second use WITHIN it; across the two, an EXISTS alone would race (two
|
|
145
|
+
-- transactions, one per table, each blind to the other's uncommitted row). So both sides
|
|
146
|
+
-- first take a transaction lock on the approval id: the second waits for the first to
|
|
147
|
+
-- commit, and its EXISTS then runs on a fresh snapshot that sees the row.
|
|
148
|
+
CREATE OR REPLACE FUNCTION payout_approval_spent_once() RETURNS trigger AS $$
|
|
149
|
+
BEGIN
|
|
150
|
+
PERFORM pg_advisory_xact_lock(1004910, hashtext(NEW.approval_id::text));
|
|
151
|
+
IF TG_TABLE_NAME = 'module_share_approvals' THEN
|
|
152
|
+
IF EXISTS (SELECT 1 FROM builder_payouts_owed WHERE approval_id = NEW.approval_id) THEN
|
|
153
|
+
RAISE EXCEPTION 'approval % already pays a task; one approval pays one thing', NEW.approval_id;
|
|
154
|
+
END IF;
|
|
155
|
+
ELSIF EXISTS (SELECT 1 FROM module_share_approvals WHERE approval_id = NEW.approval_id) THEN
|
|
156
|
+
RAISE EXCEPTION 'approval % already pays a module share; one approval pays one thing', NEW.approval_id;
|
|
157
|
+
END IF;
|
|
158
|
+
RETURN NEW;
|
|
159
|
+
END;
|
|
160
|
+
$$ LANGUAGE plpgsql;
|
|
161
|
+
|
|
162
|
+
DROP TRIGGER IF EXISTS module_share_approvals_spent_once ON module_share_approvals;
|
|
163
|
+
CREATE TRIGGER module_share_approvals_spent_once BEFORE INSERT ON module_share_approvals
|
|
164
|
+
FOR EACH ROW EXECUTE FUNCTION payout_approval_spent_once();
|
|
165
|
+
DROP TRIGGER IF EXISTS builder_payouts_owed_spent_once ON builder_payouts_owed;
|
|
166
|
+
CREATE TRIGGER builder_payouts_owed_spent_once BEFORE INSERT ON builder_payouts_owed
|
|
167
|
+
FOR EACH ROW EXECUTE FUNCTION payout_approval_spent_once();
|
|
168
|
+
|
|
169
|
+
INSERT INTO schema_migrations (version) VALUES ('core_292_module_sale_payouts')
|
|
170
|
+
ON CONFLICT DO NOTHING;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
-- core_293_cash_outs.sql — a person withdraws their own balance to their bank, with
|
|
2
|
+
-- holds, a minimum and a record of every failure (task 1004911, goal 1000092 N7;
|
|
3
|
+
-- ADR 0385 D1/D8, ADR 0397 D2/D7).
|
|
4
|
+
--
|
|
5
|
+
-- NO MONEY IS HERE. The person's money sits in their own processor-held connected
|
|
6
|
+
-- account (ADR 0397 D2); a cash-out asks the processor to pay it out from there to the
|
|
7
|
+
-- bank the person gave the processor. Bongos never holds it, never sees the bank
|
|
8
|
+
-- details, and never sums these rows into a balance.
|
|
9
|
+
--
|
|
10
|
+
-- payout_approvals gains one kind, `cashout_approved`: the person's own logged,
|
|
11
|
+
-- personal approval of ONE withdrawal of THEIR OWN money (N9). The
|
|
12
|
+
-- approver and the recipient are the same person, by CHECK, so it
|
|
13
|
+
-- can never be read as the project paying someone.
|
|
14
|
+
-- cash_outs one row per withdrawal a person asked for, written before the
|
|
15
|
+
-- processor is asked, citing that approval. One approval, one
|
|
16
|
+
-- withdrawal; one instruction id, one withdrawal.
|
|
17
|
+
-- cash_out_attempts every try at carrying it out and everything the processor said:
|
|
18
|
+
-- held (a gate or no processor), failed (no answer we can trust),
|
|
19
|
+
-- refused (the processor said no and made nothing), sent (a payout
|
|
20
|
+
-- exists), paid (it reached the bank), returned (the bank sent it
|
|
21
|
+
-- back), cancelled (the person withdrew the request).
|
|
22
|
+
--
|
|
23
|
+
-- A RETRY CANNOT PAY TWICE. Each try has a number, and the processor is called with an
|
|
24
|
+
-- idempotency key made from the withdrawal id and that number. The database computes
|
|
25
|
+
-- the number itself: 1 + the tries the processor has already said created no money
|
|
26
|
+
-- movement (refused) or were sent back (returned). So a timeout, or any answer we
|
|
27
|
+
-- cannot trust, retries with the SAME key and the processor hands back the payout it
|
|
28
|
+
-- already made; a new key exists only once the processor has said the last one did not
|
|
29
|
+
-- pay. While a try is sent and not returned, nothing but paid or returned may follow.
|
|
30
|
+
--
|
|
31
|
+
-- A FAILURE IS A RECORD. A withdrawal that has not reached the bank stays on the
|
|
32
|
+
-- person's list with what happened, in plain words, until it is paid or they cancel it;
|
|
33
|
+
-- the money was never taken from their balance. All three tables are append-only.
|
|
34
|
+
|
|
35
|
+
BEGIN;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE payout_approvals DROP CONSTRAINT IF EXISTS payout_approvals_kind_check;
|
|
38
|
+
ALTER TABLE payout_approvals ADD CONSTRAINT payout_approvals_kind_check CHECK (kind IN (
|
|
39
|
+
'rule_signed', 'rule_narrowed', 'rule_revoked',
|
|
40
|
+
'run_noticed', 'run_held',
|
|
41
|
+
'payout_approved', 'grant_signed', 'contract_signed',
|
|
42
|
+
'cashout_approved'));
|
|
43
|
+
|
|
44
|
+
ALTER TABLE payout_approvals DROP CONSTRAINT IF EXISTS payout_approvals_shape;
|
|
45
|
+
ALTER TABLE payout_approvals ADD CONSTRAINT payout_approvals_shape CHECK (
|
|
46
|
+
(kind = 'rule_signed' AND rule_id IS NULL AND run_key IS NULL AND recipient_id IS NULL AND amount_cents IS NULL AND terms <> '{}'::jsonb) OR
|
|
47
|
+
(kind = 'rule_narrowed' AND rule_id IS NOT NULL AND run_key IS NULL AND recipient_id IS NULL AND amount_cents IS NULL AND terms <> '{}'::jsonb) OR
|
|
48
|
+
(kind = 'rule_revoked' AND rule_id IS NOT NULL AND run_key IS NULL AND recipient_id IS NULL AND amount_cents IS NULL) OR
|
|
49
|
+
(kind = 'run_noticed' AND rule_id IS NOT NULL AND run_key IS NOT NULL AND amount_cents IS NOT NULL AND currency IS NOT NULL) OR
|
|
50
|
+
(kind = 'run_held' AND rule_id IS NOT NULL AND run_key IS NOT NULL AND amount_cents IS NULL) OR
|
|
51
|
+
(kind = 'payout_approved' AND rule_id IS NULL AND recipient_id IS NOT NULL AND amount_cents IS NOT NULL AND currency IS NOT NULL) OR
|
|
52
|
+
(kind IN ('grant_signed', 'contract_signed') AND rule_id IS NULL AND recipient_id IS NOT NULL AND terms <> '{}'::jsonb) OR
|
|
53
|
+
-- a person approves taking out their OWN money: approver and recipient are one person
|
|
54
|
+
(kind = 'cashout_approved' AND rule_id IS NULL AND run_key IS NULL AND recipient_id IS NOT NULL
|
|
55
|
+
AND recipient_id = actor_id AND amount_cents IS NOT NULL AND currency IS NOT NULL)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS cash_outs (
|
|
59
|
+
id bigserial PRIMARY KEY,
|
|
60
|
+
builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE RESTRICT,
|
|
61
|
+
approval_id bigint NOT NULL REFERENCES payout_approvals(id) ON DELETE RESTRICT,
|
|
62
|
+
from_account text NOT NULL CHECK (from_account ~ '^[A-Za-z0-9_.:-]{3,200}$'),
|
|
63
|
+
amount_cents bigint NOT NULL CHECK (amount_cents > 0),
|
|
64
|
+
currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
|
|
65
|
+
instruction_id text NOT NULL CHECK (instruction_id ~ '^[A-Za-z0-9_-]{8,80}$'),
|
|
66
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
-- one approval pays one withdrawal; one press of the button is one withdrawal
|
|
70
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_outs_one_per_approval ON cash_outs (approval_id);
|
|
71
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_outs_one_per_instruction ON cash_outs (builder_id, instruction_id);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS cash_outs_by_builder ON cash_outs (builder_id, id DESC);
|
|
73
|
+
|
|
74
|
+
-- The approval a withdrawal cites must be this person's own cash-out approval for this
|
|
75
|
+
-- exact amount: an owner's payout approval, or someone else's, can never be spent here.
|
|
76
|
+
CREATE OR REPLACE FUNCTION cash_outs_check_approval() RETURNS trigger
|
|
77
|
+
LANGUAGE plpgsql AS $$
|
|
78
|
+
DECLARE a record;
|
|
79
|
+
BEGIN
|
|
80
|
+
SELECT kind, actor_id, recipient_id, amount_cents, currency INTO a FROM payout_approvals WHERE id = NEW.approval_id;
|
|
81
|
+
IF a IS NULL OR a.kind <> 'cashout_approved' OR a.recipient_id <> NEW.builder_id OR a.actor_id <> NEW.builder_id
|
|
82
|
+
OR a.amount_cents <> NEW.amount_cents OR a.currency <> NEW.currency THEN
|
|
83
|
+
RAISE EXCEPTION 'a cash-out must cite the person''s own approval of exactly this withdrawal'
|
|
84
|
+
USING ERRCODE = 'check_violation';
|
|
85
|
+
END IF;
|
|
86
|
+
RETURN NEW;
|
|
87
|
+
END;
|
|
88
|
+
$$;
|
|
89
|
+
|
|
90
|
+
DROP TRIGGER IF EXISTS cash_outs_approval ON cash_outs;
|
|
91
|
+
CREATE TRIGGER cash_outs_approval BEFORE INSERT ON cash_outs
|
|
92
|
+
FOR EACH ROW EXECUTE FUNCTION cash_outs_check_approval();
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS cash_out_attempts (
|
|
95
|
+
id bigserial PRIMARY KEY,
|
|
96
|
+
cash_out_id bigint NOT NULL REFERENCES cash_outs(id) ON DELETE RESTRICT,
|
|
97
|
+
try_no integer NOT NULL CHECK (try_no >= 1),
|
|
98
|
+
outcome text NOT NULL CHECK (outcome IN ('held', 'failed', 'refused', 'sent', 'paid', 'returned', 'cancelled')),
|
|
99
|
+
processor_ref text CHECK (processor_ref IS NULL OR length(processor_ref) BETWEEN 1 AND 200),
|
|
100
|
+
reason text CHECK (reason IS NULL OR length(reason) BETWEEN 1 AND 300),
|
|
101
|
+
actor_id bigint REFERENCES builders(id) ON DELETE RESTRICT,
|
|
102
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
103
|
+
-- a payout names the processor's payout; anything that did not pay says why
|
|
104
|
+
CONSTRAINT cash_out_attempts_cause_chk CHECK (
|
|
105
|
+
(outcome IN ('sent', 'paid') AND processor_ref IS NOT NULL) OR
|
|
106
|
+
(outcome = 'returned' AND processor_ref IS NOT NULL AND reason IS NOT NULL) OR
|
|
107
|
+
(outcome IN ('held', 'failed', 'refused', 'cancelled') AND processor_ref IS NULL AND reason IS NOT NULL))
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
-- one payout per try, one arrival per try, one cancellation, and one processor payout
|
|
111
|
+
-- settles one try of one withdrawal
|
|
112
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_out_attempts_one_sent ON cash_out_attempts (cash_out_id, try_no) WHERE outcome = 'sent';
|
|
113
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_out_attempts_one_paid ON cash_out_attempts (cash_out_id, try_no) WHERE outcome = 'paid';
|
|
114
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_out_attempts_one_returned ON cash_out_attempts (cash_out_id, try_no) WHERE outcome = 'returned';
|
|
115
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_out_attempts_one_cancel ON cash_out_attempts (cash_out_id) WHERE outcome = 'cancelled';
|
|
116
|
+
CREATE UNIQUE INDEX IF NOT EXISTS cash_out_attempts_ref_once ON cash_out_attempts (processor_ref) WHERE outcome = 'sent';
|
|
117
|
+
CREATE INDEX IF NOT EXISTS cash_out_attempts_by_cash_out ON cash_out_attempts (cash_out_id, id DESC);
|
|
118
|
+
|
|
119
|
+
-- The order of a withdrawal, kept by the database whatever code writes here.
|
|
120
|
+
CREATE OR REPLACE FUNCTION cash_out_attempts_check_order() RETURNS trigger
|
|
121
|
+
LANGUAGE plpgsql AS $$
|
|
122
|
+
DECLARE
|
|
123
|
+
cur_try integer;
|
|
124
|
+
sent_ref text;
|
|
125
|
+
BEGIN
|
|
126
|
+
-- serialise every write to one withdrawal, so two checks cannot both pass
|
|
127
|
+
PERFORM 1 FROM cash_outs WHERE id = NEW.cash_out_id FOR UPDATE;
|
|
128
|
+
|
|
129
|
+
IF EXISTS (SELECT 1 FROM cash_out_attempts WHERE cash_out_id = NEW.cash_out_id AND outcome = 'cancelled') THEN
|
|
130
|
+
RAISE EXCEPTION 'this cash-out was cancelled; nothing may follow it' USING ERRCODE = 'check_violation';
|
|
131
|
+
END IF;
|
|
132
|
+
|
|
133
|
+
-- the current try: a new key only after the processor said the last one did not pay
|
|
134
|
+
SELECT 1 + count(*) INTO cur_try FROM cash_out_attempts
|
|
135
|
+
WHERE cash_out_id = NEW.cash_out_id AND outcome IN ('refused', 'returned');
|
|
136
|
+
IF NEW.try_no <> cur_try THEN
|
|
137
|
+
RAISE EXCEPTION 'a cash-out attempt must be on try % (the processor has not said try % did not pay)', cur_try, NEW.try_no
|
|
138
|
+
USING ERRCODE = 'check_violation';
|
|
139
|
+
END IF;
|
|
140
|
+
|
|
141
|
+
SELECT processor_ref INTO sent_ref FROM cash_out_attempts
|
|
142
|
+
WHERE cash_out_id = NEW.cash_out_id AND try_no = cur_try AND outcome = 'sent';
|
|
143
|
+
|
|
144
|
+
IF NEW.outcome IN ('paid', 'returned') THEN
|
|
145
|
+
IF sent_ref IS NULL OR sent_ref <> NEW.processor_ref THEN
|
|
146
|
+
RAISE EXCEPTION 'only the payout this try sent can arrive or come back' USING ERRCODE = 'check_violation';
|
|
147
|
+
END IF;
|
|
148
|
+
ELSIF sent_ref IS NOT NULL THEN
|
|
149
|
+
-- the try is sent and not returned (a return would have moved cur_try on)
|
|
150
|
+
RAISE EXCEPTION 'this cash-out is already sent; only its arrival or return may follow' USING ERRCODE = 'check_violation';
|
|
151
|
+
END IF;
|
|
152
|
+
RETURN NEW;
|
|
153
|
+
END;
|
|
154
|
+
$$;
|
|
155
|
+
|
|
156
|
+
DROP TRIGGER IF EXISTS cash_out_attempts_order ON cash_out_attempts;
|
|
157
|
+
CREATE TRIGGER cash_out_attempts_order BEFORE INSERT ON cash_out_attempts
|
|
158
|
+
FOR EACH ROW EXECUTE FUNCTION cash_out_attempts_check_order();
|
|
159
|
+
|
|
160
|
+
CREATE OR REPLACE FUNCTION cash_outs_append_only() RETURNS trigger
|
|
161
|
+
LANGUAGE plpgsql AS $$
|
|
162
|
+
BEGIN
|
|
163
|
+
RAISE EXCEPTION '% is append-only: a cash-out is never edited or deleted, a new attempt is recorded instead', TG_TABLE_NAME
|
|
164
|
+
USING ERRCODE = 'insufficient_privilege';
|
|
165
|
+
END;
|
|
166
|
+
$$;
|
|
167
|
+
|
|
168
|
+
DROP TRIGGER IF EXISTS cash_outs_no_rewrite ON cash_outs;
|
|
169
|
+
CREATE TRIGGER cash_outs_no_rewrite BEFORE UPDATE OR DELETE ON cash_outs
|
|
170
|
+
FOR EACH ROW EXECUTE FUNCTION cash_outs_append_only();
|
|
171
|
+
DROP TRIGGER IF EXISTS cash_out_attempts_no_rewrite ON cash_out_attempts;
|
|
172
|
+
CREATE TRIGGER cash_out_attempts_no_rewrite BEFORE UPDATE OR DELETE ON cash_out_attempts
|
|
173
|
+
FOR EACH ROW EXECUTE FUNCTION cash_outs_append_only();
|
|
174
|
+
DROP TRIGGER IF EXISTS cash_outs_no_truncate ON cash_outs;
|
|
175
|
+
CREATE TRIGGER cash_outs_no_truncate BEFORE TRUNCATE ON cash_outs
|
|
176
|
+
FOR EACH STATEMENT EXECUTE FUNCTION cash_outs_append_only();
|
|
177
|
+
DROP TRIGGER IF EXISTS cash_out_attempts_no_truncate ON cash_out_attempts;
|
|
178
|
+
CREATE TRIGGER cash_out_attempts_no_truncate BEFORE TRUNCATE ON cash_out_attempts
|
|
179
|
+
FOR EACH STATEMENT EXECUTE FUNCTION cash_outs_append_only();
|
|
180
|
+
|
|
181
|
+
INSERT INTO schema_migrations (version) VALUES ('core_293_cash_outs')
|
|
182
|
+
ON CONFLICT DO NOTHING;
|
|
183
|
+
|
|
184
|
+
COMMIT;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- core_294_cash_out_attempts_sent_by_time.sql — the daily ledger check reads sent cash-outs
|
|
2
|
+
-- by time without scanning every attempt (task 1004912, goal 1000092 N8).
|
|
3
|
+
--
|
|
4
|
+
-- The economy module's daily check (modules/economy/ledger-check.js) reads each `sent`
|
|
5
|
+
-- cash-out attempt of the last week to compare it with the processor's payouts. The table is
|
|
6
|
+
-- append-only and keeps every try forever, and its only `sent` index is by reference, so the
|
|
7
|
+
-- week's read would scan the whole table. A partial index over just the sent rows by time
|
|
8
|
+
-- lets it read those alone. A core migration because cash_out_attempts is core's table
|
|
9
|
+
-- (core_293), however much the economy module wants the index.
|
|
10
|
+
--
|
|
11
|
+
-- Additive: one index, nothing else.
|
|
12
|
+
|
|
13
|
+
BEGIN;
|
|
14
|
+
|
|
15
|
+
CREATE INDEX IF NOT EXISTS cash_out_attempts_sent_by_time
|
|
16
|
+
ON cash_out_attempts (created_at) WHERE outcome = 'sent';
|
|
17
|
+
|
|
18
|
+
INSERT INTO schema_migrations (version) VALUES ('core_294_cash_out_attempts_sent_by_time')
|
|
19
|
+
ON CONFLICT DO NOTHING;
|
|
20
|
+
|
|
21
|
+
COMMIT;
|