@birtalanrobert/commerce 1.0.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +601 -0
  2. package/LICENSE +661 -0
  3. package/NOTICE +45 -0
  4. package/README.md +79 -0
  5. package/dist/deposits.d.ts +52 -0
  6. package/dist/deposits.d.ts.map +1 -0
  7. package/dist/deposits.js +71 -0
  8. package/dist/deposits.js.map +1 -0
  9. package/dist/index.d.ts +23 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +30 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/migrations/1789800000000-CreateCommerce.d.ts +38 -0
  14. package/dist/migrations/1789800000000-CreateCommerce.d.ts.map +1 -0
  15. package/dist/migrations/1789800000000-CreateCommerce.js +152 -0
  16. package/dist/migrations/1789800000000-CreateCommerce.js.map +1 -0
  17. package/dist/nestjs/commerce.service.d.ts +122 -0
  18. package/dist/nestjs/commerce.service.d.ts.map +1 -0
  19. package/dist/nestjs/commerce.service.js +337 -0
  20. package/dist/nestjs/commerce.service.js.map +1 -0
  21. package/dist/nestjs/index.d.ts +18 -0
  22. package/dist/nestjs/index.d.ts.map +1 -0
  23. package/dist/nestjs/index.js +27 -0
  24. package/dist/nestjs/index.js.map +1 -0
  25. package/dist/nestjs/payment.entity.d.ts +94 -0
  26. package/dist/nestjs/payment.entity.d.ts.map +1 -0
  27. package/dist/nestjs/payment.entity.js +181 -0
  28. package/dist/nestjs/payment.entity.js.map +1 -0
  29. package/dist/nestjs/payout-account.entity.d.ts +41 -0
  30. package/dist/nestjs/payout-account.entity.d.ts.map +1 -0
  31. package/dist/nestjs/payout-account.entity.js +83 -0
  32. package/dist/nestjs/payout-account.entity.js.map +1 -0
  33. package/dist/providers/port.d.ts +97 -0
  34. package/dist/providers/port.d.ts.map +1 -0
  35. package/dist/providers/port.js +16 -0
  36. package/dist/providers/port.js.map +1 -0
  37. package/dist/providers/stripe.d.ts +39 -0
  38. package/dist/providers/stripe.d.ts.map +1 -0
  39. package/dist/providers/stripe.js +221 -0
  40. package/dist/providers/stripe.js.map +1 -0
  41. package/nestjs/package.json +5 -0
  42. package/package.json +49 -0
  43. package/src/deposits.ts +96 -0
  44. package/src/index.ts +40 -0
  45. package/src/migrations/1789800000000-CreateCommerce.ts +156 -0
  46. package/src/nestjs/commerce.service.ts +476 -0
  47. package/src/nestjs/index.ts +24 -0
  48. package/src/nestjs/payment.entity.ts +150 -0
  49. package/src/nestjs/payout-account.entity.ts +56 -0
  50. package/src/providers/port.ts +108 -0
  51. package/src/providers/stripe.ts +274 -0
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.StripeConnect = void 0;
7
+ const stripe_1 = __importDefault(require("stripe"));
8
+ /**
9
+ * Stripe Connect, as the port describes it.
10
+ *
11
+ * **Destination charges throughout.** The money is created on our platform
12
+ * account and transferred immediately to the business, with our cut taken as an
13
+ * application fee — which is what keeps this a software company rather than a
14
+ * regulated one, and what lets the business see its own payouts in its own
15
+ * Stripe dashboard.
16
+ *
17
+ * Everything the provider does *not* need to decide is decided before this file
18
+ * is reached: what a deposit comes to, whether a business may sell, what a
19
+ * refund leaves. What is here is the part that genuinely needs somebody else's
20
+ * money-moving licence.
21
+ */
22
+ class StripeConnect {
23
+ options;
24
+ name = 'stripe';
25
+ stripe;
26
+ constructor(options) {
27
+ this.options = options;
28
+ this.stripe = options.client ?? new stripe_1.default(options.secretKey);
29
+ }
30
+ async account(externalId, country, email) {
31
+ const account = externalId
32
+ ? await this.stripe.accounts.retrieve(externalId)
33
+ : await this.stripe.accounts.create({
34
+ type: 'express',
35
+ country,
36
+ ...(email ? { email } : {}),
37
+ capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
38
+ });
39
+ return interpretAccount(account);
40
+ }
41
+ async onboard(externalId, returnUrl, refreshUrl) {
42
+ const link = await this.stripe.accountLinks.create({
43
+ account: externalId,
44
+ type: 'account_onboarding',
45
+ return_url: returnUrl,
46
+ /*
47
+ * Where the business lands if the link has aged out.
48
+ *
49
+ * Stripe's onboarding links are short-lived and somebody *will* open one
50
+ * the next morning. Without this they meet an error page from a company
51
+ * they have never heard of, halfway through giving it their passport.
52
+ */
53
+ refresh_url: refreshUrl,
54
+ });
55
+ return { url: link.url, expiresAt: new Date(link.expires_at * 1000) };
56
+ }
57
+ async charge(request) {
58
+ try {
59
+ const intent = await this.stripe.paymentIntents.create({
60
+ amount: request.amount,
61
+ currency: request.currency.toLowerCase(),
62
+ /*
63
+ * The money lands on the business's account, not ours.
64
+ *
65
+ * `transfer_data.destination` with `application_fee_amount` is the
66
+ * destination-charge shape: we never hold their funds, and their
67
+ * payouts appear in their own dashboard.
68
+ */
69
+ transfer_data: { destination: request.account },
70
+ ...(request.applicationFee > 0 ? { application_fee_amount: request.applicationFee } : {}),
71
+ capture_method: request.capture ? 'automatic' : 'manual',
72
+ ...(request.description ? { description: request.description } : {}),
73
+ // Carried through so a webhook can be matched back to what it paid
74
+ // for without a lookup table of our own.
75
+ metadata: { subject: request.subject },
76
+ automatic_payment_methods: { enabled: true },
77
+ },
78
+ // The provider's own idempotency, so a retried request — a timeout, a
79
+ // double submit — does not charge somebody twice.
80
+ { idempotencyKey: request.reference });
81
+ return interpretIntent(intent);
82
+ }
83
+ catch (error) {
84
+ return failure(error);
85
+ }
86
+ }
87
+ async capture(externalId, amount) {
88
+ try {
89
+ const intent = await this.stripe.paymentIntents.capture(externalId, amount === undefined ? undefined : { amount_to_capture: amount });
90
+ return interpretIntent(intent);
91
+ }
92
+ catch (error) {
93
+ return failure(error);
94
+ }
95
+ }
96
+ async release(externalId) {
97
+ await this.stripe.paymentIntents.cancel(externalId);
98
+ }
99
+ async refund(request) {
100
+ const refund = await this.stripe.refunds.create({
101
+ payment_intent: request.externalId,
102
+ amount: request.amount,
103
+ metadata: { reason: request.reason.slice(0, 500) },
104
+ }, { idempotencyKey: request.reference });
105
+ return { externalId: refund.id };
106
+ }
107
+ verify(payload, signature) {
108
+ if (!signature || !this.options.webhookSecret)
109
+ return undefined;
110
+ let event;
111
+ try {
112
+ event = this.stripe.webhooks.constructEvent(payload, signature, this.options.webhookSecret);
113
+ }
114
+ catch {
115
+ /*
116
+ * A forgery, or a payload something re-encoded on the way in.
117
+ *
118
+ * `undefined` rather than a thrown error: the caller answers a request
119
+ * that did not come from Stripe with a flat acknowledgement, not with a
120
+ * message describing what was wrong with it.
121
+ */
122
+ return undefined;
123
+ }
124
+ return interpretEvent(event);
125
+ }
126
+ }
127
+ exports.StripeConnect = StripeConnect;
128
+ /** Stripe's account shape, reduced to the question anybody actually asks. */
129
+ function interpretAccount(account) {
130
+ const requirements = [
131
+ ...(account.requirements?.currently_due ?? []),
132
+ ...(account.requirements?.past_due ?? []),
133
+ ];
134
+ /*
135
+ * `charges_enabled` and `payouts_enabled` together, not either alone.
136
+ *
137
+ * A business that can take money but cannot be paid out is worse than one
138
+ * that cannot sell yet: the customer is charged and the money sits with the
139
+ * provider, and the first anybody hears is the business asking where it is.
140
+ */
141
+ const ready = account.charges_enabled === true && account.payouts_enabled === true;
142
+ return {
143
+ externalId: account.id,
144
+ status: ready ? 'ready' : requirements.length > 0 ? 'restricted' : 'pending',
145
+ requirements: [...new Set(requirements)],
146
+ };
147
+ }
148
+ function interpretIntent(intent) {
149
+ const state = intent.status === 'succeeded'
150
+ ? 'captured'
151
+ : intent.status === 'requires_capture'
152
+ ? 'authorized'
153
+ : intent.status === 'canceled'
154
+ ? 'failed'
155
+ : 'pending';
156
+ const charge = intent.latest_charge;
157
+ const card = typeof charge === 'object' && charge?.payment_method_details?.card
158
+ ? `${charge.payment_method_details.card.brand} ending ${charge.payment_method_details.card.last4}`
159
+ : undefined;
160
+ return {
161
+ externalId: intent.id,
162
+ state,
163
+ ...(intent.next_action?.redirect_to_url?.url
164
+ ? { redirectUrl: intent.next_action.redirect_to_url.url }
165
+ : {}),
166
+ ...(card ? { instrument: card } : {}),
167
+ ...(intent.last_payment_error?.message ? { detail: intent.last_payment_error.message } : {}),
168
+ };
169
+ }
170
+ function interpretEvent(event) {
171
+ switch (event.type) {
172
+ case 'payment_intent.succeeded':
173
+ case 'payment_intent.amount_capturable_updated':
174
+ case 'payment_intent.payment_failed': {
175
+ const intent = event.data.object;
176
+ const result = interpretIntent(intent);
177
+ return {
178
+ kind: 'payment',
179
+ externalId: intent.id,
180
+ state: event.type === 'payment_intent.payment_failed'
181
+ ? 'failed'
182
+ : result.state === 'captured'
183
+ ? 'captured'
184
+ : 'authorized',
185
+ ...(result.instrument ? { instrument: result.instrument } : {}),
186
+ ...(result.detail ? { detail: result.detail } : {}),
187
+ };
188
+ }
189
+ case 'charge.refunded': {
190
+ const charge = event.data.object;
191
+ return {
192
+ kind: 'payment',
193
+ externalId: typeof charge.payment_intent === 'string' ? charge.payment_intent : charge.id,
194
+ state: 'refunded',
195
+ };
196
+ }
197
+ case 'account.updated': {
198
+ const account = event.data.object;
199
+ return { kind: 'account', externalId: account.id, accountStatus: interpretAccount(account) };
200
+ }
201
+ default:
202
+ /*
203
+ * Everything else is acknowledged and ignored.
204
+ *
205
+ * Stripe sends a great many event types and a deployment's subscription
206
+ * will drift; treating an unknown one as an error means retries and an
207
+ * alert for something that was never any of our business.
208
+ */
209
+ return { kind: 'other', externalId: event.id };
210
+ }
211
+ }
212
+ /** A provider's refusal, as a result rather than an exception. */
213
+ function failure(error) {
214
+ const message = error instanceof stripe_1.default.errors.StripeError
215
+ ? (error.message ?? 'The payment was refused.')
216
+ : error instanceof Error
217
+ ? error.message
218
+ : 'The payment was refused.';
219
+ return { externalId: '', state: 'failed', detail: message };
220
+ }
221
+ //# sourceMappingURL=stripe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stripe.js","sourceRoot":"","sources":["../../src/providers/stripe.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ {
2
+ "main": "../dist/nestjs/index.js",
3
+ "types": "../dist/nestjs/index.d.ts",
4
+ "sideEffects": false
5
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@birtalanrobert/commerce",
3
+ "version": "1.0.0",
4
+ "description": "Taking money on a business's behalf: payout onboarding, payments, refunds and the record of both",
5
+ "license": "AGPL-3.0-only",
6
+ "author": "Robert Birtalan",
7
+ "type": "commonjs",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./nestjs": {
16
+ "types": "./dist/nestjs/index.d.ts",
17
+ "default": "./dist/nestjs/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "!src/**/*.test.ts",
25
+ "!src/**/*.spec.ts",
26
+ "README.md",
27
+ "LICENSE",
28
+ "NOTICE",
29
+ "nestjs",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "dependencies": {
33
+ "stripe": "^22.6.0",
34
+ "@birtalanrobert/context": "^1.1.0",
35
+ "@birtalanrobert/money": "^1.0.0",
36
+ "@birtalanrobert/database": "^1.0.0",
37
+ "@birtalanrobert/tenancy": "^1.1.0",
38
+ "@birtalanrobert/http": "^2.0.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@nestjs/common": "^11.0.0",
42
+ "typeorm": "^1.0.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "clean": "rm -rf dist *.tsbuildinfo",
47
+ "typecheck": "tsc -p tsconfig.json --noEmit"
48
+ }
49
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What a customer has to pay up front, worked out the way a person would check.
3
+ *
4
+ * **Pure, and at the root entry point on purpose.** A console shows "30% of
5
+ * 150,00 lei is 45,00 lei" while somebody drags a slider, and a booking page
6
+ * shows the same number before anybody types a card into it. Neither may reach
7
+ * for a database, and both must arrive at exactly the number the charge will be.
8
+ */
9
+
10
+ /** How a business asks for money before the work. */
11
+ export type DepositKind = 'none' | 'percentage' | 'fixed' | 'full';
12
+
13
+ export interface DepositPolicy {
14
+ readonly kind: DepositKind;
15
+ /**
16
+ * Whole percent for `percentage`, minor units for `fixed`, ignored otherwise.
17
+ *
18
+ * Whole percent rather than a fraction because it is typed into a box by a
19
+ * person: "30" is what a tattoo studio says, and `0.3` is what turns into
20
+ * `0.30000000000000004` two operations later.
21
+ */
22
+ readonly value: number;
23
+ }
24
+
25
+ /**
26
+ * The deposit for a given total.
27
+ *
28
+ * **Rounded to the nearest minor unit, and never past the total.** A percentage
29
+ * of an odd amount is a fraction of a ban, and a deposit larger than the price
30
+ * is what a misconfigured 150% produces — both are refused here rather than at
31
+ * the provider, where the message is in English and mentions an integer.
32
+ */
33
+ export function depositFor(total: number, policy: DepositPolicy): number {
34
+ if (total <= 0) return 0;
35
+
36
+ switch (policy.kind) {
37
+ case 'none':
38
+ return 0;
39
+ case 'full':
40
+ return total;
41
+ case 'fixed':
42
+ // A fixed deposit above the price is a configuration mistake, and taking
43
+ // more than the thing costs is the worst possible way to surface it.
44
+ return clamp(Math.round(policy.value), total);
45
+ case 'percentage':
46
+ /*
47
+ * Rounded half away from zero, which is what a person doing it by hand
48
+ * produces. `Math.round` rounds half *up*, which differs on negatives —
49
+ * irrelevant here because a total is never negative, and stated so that
50
+ * the day it is, this is the line to look at.
51
+ */
52
+ return clamp(Math.round((total * clamp(policy.value, 100)) / 100), total);
53
+ default:
54
+ return 0;
55
+ }
56
+ }
57
+
58
+ /** What is still owed after a deposit. Never negative, whatever was paid. */
59
+ export const balanceAfter = (total: number, paid: number): number => Math.max(0, total - paid);
60
+
61
+ const clamp = (value: number, max: number): number => Math.min(Math.max(0, value), max);
62
+
63
+ /**
64
+ * Whether a business may take money at all.
65
+ *
66
+ * The provider's onboarding is the gate, and it is a hard one: funds go
67
+ * directly to the business and our fee is taken on top, so until the provider
68
+ * has verified who they are there is nowhere for the money to go. Every
69
+ * consumer of this package has to answer the same question in front of the
70
+ * same button, which is why it is here rather than written three times.
71
+ */
72
+ export type PayoutStatus = 'none' | 'pending' | 'restricted' | 'ready';
73
+
74
+ export const canTakeMoney = (status: PayoutStatus): boolean => status === 'ready';
75
+
76
+ /**
77
+ * What to tell a business that cannot yet, in the order it becomes true.
78
+ *
79
+ * Returned as a key rather than a sentence: the words belong to whichever
80
+ * product is showing them, and a package that shipped English into a Romanian
81
+ * console would be worse than one that shipped nothing.
82
+ */
83
+ export const payoutBlockReason = (
84
+ status: PayoutStatus,
85
+ ): 'not-started' | 'in-progress' | 'needs-attention' | null => {
86
+ switch (status) {
87
+ case 'none':
88
+ return 'not-started';
89
+ case 'pending':
90
+ return 'in-progress';
91
+ case 'restricted':
92
+ return 'needs-attention';
93
+ case 'ready':
94
+ return null;
95
+ }
96
+ };
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Taking money on a business's behalf.
3
+ *
4
+ * **We never hold anybody's funds.** The customer pays the business directly
5
+ * and our fee is taken on top as an application fee — a hard architectural rule
6
+ * rather than a preference, because holding third-party money turns a software
7
+ * company into a regulated payments business. Every design decision here
8
+ * follows from it.
9
+ *
10
+ * Not to be confused with `@birtalanrobert/billing`, which is the other
11
+ * direction: the business paying *us* for a subscription. Conflating the two is
12
+ * the mistake that makes both hard to reason about — they differ in who pays
13
+ * whom, in which Stripe account, and in what happens when one fails.
14
+ *
15
+ * **This entry point is pure.** What a deposit comes to and whether a business
16
+ * may sell yet are decided without a database or a provider, because a console
17
+ * shows both while somebody drags a slider. Everything needing storage or
18
+ * somebody else's money-moving licence is behind `/nestjs`.
19
+ */
20
+ export {
21
+ balanceAfter,
22
+ canTakeMoney,
23
+ depositFor,
24
+ payoutBlockReason,
25
+ type DepositKind,
26
+ type DepositPolicy,
27
+ type PayoutStatus,
28
+ } from './deposits';
29
+
30
+ export type {
31
+ ChargeRequest,
32
+ ChargeResult,
33
+ OnboardingLink,
34
+ PaymentProvider,
35
+ ProviderAccount,
36
+ ProviderEvent,
37
+ RefundRequest,
38
+ } from './providers/port';
39
+
40
+ export { StripeConnect, type StripeConnectOptions } from './providers/stripe';
@@ -0,0 +1,156 @@
1
+ import type { MigrationInterface, QueryRunner } from 'typeorm';
2
+ import { enableRlsSql } from '@birtalanrobert/tenancy';
3
+
4
+ /**
5
+ * Taking money on a business's behalf.
6
+ *
7
+ * **We never hold anybody's funds.** The customer pays the business directly
8
+ * and our fee is taken on top — a hard architectural rule rather than a
9
+ * preference, because holding third-party money turns a software company into a
10
+ * regulated payments business. Everything here follows from that.
11
+ *
12
+ * ## Three tables, and why each is separate
13
+ *
14
+ * `mortar_payout_accounts` is the gate. Until the provider has verified who a
15
+ * business is, there is nowhere for a payment to land, and every product that
16
+ * sells on somebody's behalf has to answer that same question in front of the
17
+ * same button.
18
+ *
19
+ * `mortar_payments` is the record, and it **outlives the provider**: amounts,
20
+ * dates, what it was for and who decided are stored in full rather than as
21
+ * identifiers to fetch later. A business must be able to produce its own
22
+ * takings years after the provider account is closed or the vendor replaced.
23
+ *
24
+ * `mortar_payment_refunds` is one row per act of giving money back. Several
25
+ * partial refunds against one payment is ordinary, and a single column cannot
26
+ * say when each happened or why.
27
+ *
28
+ * ## No foreign key to whatever was paid for
29
+ *
30
+ * One product takes a deposit against an appointment, another against a seat, a
31
+ * third against a table's tab. A key to any one of them is exactly what would
32
+ * stop this table being shared, so the subject is a string the owning product
33
+ * writes and reads.
34
+ */
35
+ export class CreateCommerce1789800000000 implements MigrationInterface {
36
+ name = 'CreateCommerce1789800000000';
37
+
38
+ public async up(queryRunner: QueryRunner): Promise<void> {
39
+ await queryRunner.query(`
40
+ CREATE TABLE "mortar_payout_accounts" (
41
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
42
+ "created_at" timestamptz NOT NULL DEFAULT now(),
43
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
44
+ "tenant_id" uuid NOT NULL,
45
+ "provider" varchar(32) NOT NULL DEFAULT 'stripe',
46
+ "external_id" varchar(128) NOT NULL,
47
+ "status" varchar(16) NOT NULL DEFAULT 'pending',
48
+ -- What the provider still wants, in its own words. "A photograph of the
49
+ -- director's identity document" is actionable; "restricted" is a
50
+ -- support conversation.
51
+ "requirements" jsonb NOT NULL DEFAULT '[]'::jsonb,
52
+ "ready_at" timestamptz,
53
+ CONSTRAINT "pk_payout_accounts" PRIMARY KEY ("id"),
54
+ CONSTRAINT "uq_payout_accounts_tenant" UNIQUE ("tenant_id", "provider"),
55
+ CONSTRAINT "ck_payout_accounts_status"
56
+ CHECK ("status" IN ('none', 'pending', 'restricted', 'ready'))
57
+ )
58
+ `);
59
+
60
+ /* A webhook arrives naming the provider's account, not ours. */
61
+ await queryRunner.query(`
62
+ CREATE INDEX "ix_payout_accounts_external"
63
+ ON "mortar_payout_accounts" ("provider", "external_id")
64
+ `);
65
+
66
+ await queryRunner.query(`
67
+ CREATE TABLE "mortar_payments" (
68
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
69
+ "created_at" timestamptz NOT NULL DEFAULT now(),
70
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
71
+ "tenant_id" uuid NOT NULL,
72
+ -- What it was for, as the owning product names it. No foreign key: see
73
+ -- the note above.
74
+ "subject" varchar(160) NOT NULL,
75
+ -- 'card' is the only one this package processes. The rest are recorded
76
+ -- rather than taken, and recording them is not a lesser feature: a
77
+ -- report that counts only what a provider processed tells a salon a
78
+ -- fraction of its own takings and looks complete.
79
+ "method" varchar(16) NOT NULL,
80
+ "state" varchar(24) NOT NULL DEFAULT 'pending',
81
+ "amount" bigint NOT NULL,
82
+ "currency" varchar(3) NOT NULL,
83
+ -- Recorded even when zero: "charged nothing" and "nobody wrote down
84
+ -- what was charged" are different facts.
85
+ "application_fee" bigint NOT NULL DEFAULT 0,
86
+ "refunded" bigint NOT NULL DEFAULT 0,
87
+ "provider" varchar(32),
88
+ "external_id" varchar(128),
89
+ -- "Visa ending 4242". Never the number, never anything chargeable from
90
+ -- a database dump.
91
+ "instrument" varchar(40),
92
+ "taken_at" timestamptz,
93
+ "detail" varchar(400),
94
+ "recorded_by" uuid,
95
+ CONSTRAINT "pk_payments" PRIMARY KEY ("id"),
96
+ CONSTRAINT "uq_payments_tenant_id" UNIQUE ("tenant_id", "id"),
97
+ CONSTRAINT "ck_payments_method"
98
+ CHECK ("method" IN ('card', 'cash', 'terminal', 'voucher', 'transfer')),
99
+ CONSTRAINT "ck_payments_state"
100
+ CHECK ("state" IN ('pending', 'authorized', 'captured', 'failed',
101
+ 'refunded', 'partially_refunded', 'cancelled')),
102
+ -- Money is never negative here, and nothing may be given back twice.
103
+ CONSTRAINT "ck_payments_amount" CHECK ("amount" >= 0),
104
+ CONSTRAINT "ck_payments_refunded"
105
+ CHECK ("refunded" >= 0 AND "refunded" <= "amount")
106
+ )
107
+ `);
108
+
109
+ await queryRunner.query(`
110
+ CREATE INDEX "ix_payments_subject" ON "mortar_payments" ("tenant_id", "subject")
111
+ `);
112
+ /* Every revenue report reads this: a tenant's takings over a period. */
113
+ await queryRunner.query(`
114
+ CREATE INDEX "ix_payments_taken" ON "mortar_payments" ("tenant_id", "taken_at")
115
+ `);
116
+ /* And a webhook arrives naming the provider's payment. */
117
+ await queryRunner.query(`
118
+ CREATE INDEX "ix_payments_external" ON "mortar_payments" ("provider", "external_id")
119
+ `);
120
+
121
+ await queryRunner.query(`
122
+ CREATE TABLE "mortar_payment_refunds" (
123
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
124
+ "created_at" timestamptz NOT NULL DEFAULT now(),
125
+ "updated_at" timestamptz NOT NULL DEFAULT now(),
126
+ "tenant_id" uuid NOT NULL,
127
+ "payment_id" uuid NOT NULL,
128
+ "amount" bigint NOT NULL,
129
+ -- Required. "We refunded her ninety lei in March" is a question
130
+ -- somebody asks a year later, and a blank reason cannot be defended.
131
+ "reason" varchar(400) NOT NULL,
132
+ "external_id" varchar(128),
133
+ "refunded_by" uuid,
134
+ CONSTRAINT "pk_payment_refunds" PRIMARY KEY ("id"),
135
+ CONSTRAINT "fk_payment_refunds_payment" FOREIGN KEY ("tenant_id", "payment_id")
136
+ REFERENCES "mortar_payments" ("tenant_id", "id") ON DELETE RESTRICT,
137
+ CONSTRAINT "ck_payment_refunds_amount" CHECK ("amount" > 0)
138
+ )
139
+ `);
140
+
141
+ await queryRunner.query(`
142
+ CREATE INDEX "ix_payment_refunds_payment"
143
+ ON "mortar_payment_refunds" ("tenant_id", "payment_id")
144
+ `);
145
+
146
+ for (const table of ['mortar_payout_accounts', 'mortar_payments', 'mortar_payment_refunds']) {
147
+ for (const statement of enableRlsSql(table)) await queryRunner.query(statement);
148
+ }
149
+ }
150
+
151
+ public async down(queryRunner: QueryRunner): Promise<void> {
152
+ await queryRunner.query(`DROP TABLE IF EXISTS "mortar_payment_refunds" CASCADE`);
153
+ await queryRunner.query(`DROP TABLE IF EXISTS "mortar_payments" CASCADE`);
154
+ await queryRunner.query(`DROP TABLE IF EXISTS "mortar_payout_accounts" CASCADE`);
155
+ }
156
+ }