@birtalanrobert/commerce 1.0.0 → 3.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.
- package/README.md +9 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -7
- package/dist/index.js.map +1 -1
- package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts +30 -0
- package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts.map +1 -0
- package/dist/migrations/1790000000000-AddSavedCardsAndKind.js +89 -0
- package/dist/migrations/1790000000000-AddSavedCardsAndKind.js.map +1 -0
- package/dist/nestjs/commerce.service.d.ts +65 -1
- package/dist/nestjs/commerce.service.d.ts.map +1 -1
- package/dist/nestjs/commerce.service.js +98 -2
- package/dist/nestjs/commerce.service.js.map +1 -1
- package/dist/nestjs/index.d.ts +6 -3
- package/dist/nestjs/index.d.ts.map +1 -1
- package/dist/nestjs/index.js +9 -3
- package/dist/nestjs/index.js.map +1 -1
- package/dist/nestjs/payment.entity.d.ts +11 -19
- package/dist/nestjs/payment.entity.d.ts.map +1 -1
- package/dist/nestjs/payment.entity.js +13 -0
- package/dist/nestjs/payment.entity.js.map +1 -1
- package/dist/nestjs/saved-card.entity.d.ts +42 -0
- package/dist/nestjs/saved-card.entity.d.ts.map +1 -0
- package/dist/nestjs/saved-card.entity.js +109 -0
- package/dist/nestjs/saved-card.entity.js.map +1 -0
- package/dist/payments.d.ts +54 -0
- package/dist/payments.d.ts.map +1 -0
- package/dist/payments.js +30 -0
- package/dist/payments.js.map +1 -0
- package/dist/providers/port.d.ts +68 -0
- package/dist/providers/port.d.ts.map +1 -1
- package/dist/providers/stripe.d.ts +12 -1
- package/dist/providers/stripe.d.ts.map +1 -1
- package/dist/providers/stripe.js +71 -1
- package/dist/providers/stripe.js.map +1 -1
- package/package.json +16 -3
- package/src/index.ts +27 -5
- package/src/migrations/1790000000000-AddSavedCardsAndKind.ts +92 -0
- package/src/nestjs/commerce.service.ts +170 -3
- package/src/nestjs/index.ts +15 -3
- package/src/nestjs/payment.entity.ts +17 -25
- package/src/nestjs/saved-card.entity.ts +69 -0
- package/src/payments.ts +67 -0
- package/src/providers/port.ts +74 -0
- package/src/providers/stripe.ts +90 -1
- package/stripe/package.json +5 -0
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@birtalanrobert/commerce",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Taking money on a business's behalf: payout onboarding, payments, refunds and the record of both",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Robert Birtalan",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/birtalanrobert/mortar.git",
|
|
10
|
+
"directory": "packages/commerce"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
7
15
|
"type": "commonjs",
|
|
8
16
|
"main": "./dist/index.js",
|
|
9
17
|
"types": "./dist/index.d.ts",
|
|
@@ -12,6 +20,10 @@
|
|
|
12
20
|
"types": "./dist/index.d.ts",
|
|
13
21
|
"default": "./dist/index.js"
|
|
14
22
|
},
|
|
23
|
+
"./stripe": {
|
|
24
|
+
"types": "./dist/providers/stripe.d.ts",
|
|
25
|
+
"default": "./dist/providers/stripe.js"
|
|
26
|
+
},
|
|
15
27
|
"./nestjs": {
|
|
16
28
|
"types": "./dist/nestjs/index.d.ts",
|
|
17
29
|
"default": "./dist/nestjs/index.js"
|
|
@@ -26,16 +38,17 @@
|
|
|
26
38
|
"README.md",
|
|
27
39
|
"LICENSE",
|
|
28
40
|
"NOTICE",
|
|
41
|
+
"stripe",
|
|
29
42
|
"nestjs",
|
|
30
43
|
"CHANGELOG.md"
|
|
31
44
|
],
|
|
32
45
|
"dependencies": {
|
|
33
46
|
"stripe": "^22.6.0",
|
|
34
47
|
"@birtalanrobert/context": "^1.1.0",
|
|
48
|
+
"@birtalanrobert/http": "^2.0.0",
|
|
35
49
|
"@birtalanrobert/money": "^1.0.0",
|
|
36
50
|
"@birtalanrobert/database": "^1.0.0",
|
|
37
|
-
"@birtalanrobert/tenancy": "^1.1.0"
|
|
38
|
-
"@birtalanrobert/http": "^2.0.0"
|
|
51
|
+
"@birtalanrobert/tenancy": "^1.1.0"
|
|
39
52
|
},
|
|
40
53
|
"peerDependencies": {
|
|
41
54
|
"@nestjs/common": "^11.0.0",
|
package/src/index.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* the mistake that makes both hard to reason about — they differ in who pays
|
|
13
13
|
* whom, in which Stripe account, and in what happens when one fails.
|
|
14
14
|
*
|
|
15
|
-
* **This entry point is pure
|
|
16
|
-
*
|
|
17
|
-
* shows both while somebody drags a
|
|
18
|
-
*
|
|
15
|
+
* **This entry point is pure**, and stays that way by leaving things out. What
|
|
16
|
+
* a deposit comes to and whether a business may sell yet are decided without a
|
|
17
|
+
* database or a provider, because a console shows both while somebody drags a
|
|
18
|
+
* slider. Storage and the container are behind `/nestjs`; the Stripe client is
|
|
19
|
+
* behind `/stripe`, because a barrel that re-exports it puts the vendor's whole
|
|
20
|
+
* SDK into every bundle that wanted to divide a price by three.
|
|
19
21
|
*/
|
|
20
22
|
export {
|
|
21
23
|
balanceAfter,
|
|
@@ -27,6 +29,14 @@ export {
|
|
|
27
29
|
type PayoutStatus,
|
|
28
30
|
} from './deposits';
|
|
29
31
|
|
|
32
|
+
export {
|
|
33
|
+
isRefundable,
|
|
34
|
+
refundableAmount,
|
|
35
|
+
type PaymentKind,
|
|
36
|
+
type PaymentMethod,
|
|
37
|
+
type PaymentState,
|
|
38
|
+
} from './payments';
|
|
39
|
+
|
|
30
40
|
export type {
|
|
31
41
|
ChargeRequest,
|
|
32
42
|
ChargeResult,
|
|
@@ -35,6 +45,18 @@ export type {
|
|
|
35
45
|
ProviderAccount,
|
|
36
46
|
ProviderEvent,
|
|
37
47
|
RefundRequest,
|
|
48
|
+
SaveCardRequest,
|
|
49
|
+
SaveCardResult,
|
|
50
|
+
StoredCard,
|
|
38
51
|
} from './providers/port';
|
|
39
52
|
|
|
40
|
-
|
|
53
|
+
/*
|
|
54
|
+
* `StripeConnect` is deliberately NOT re-exported here.
|
|
55
|
+
*
|
|
56
|
+
* It lives at `@birtalanrobert/commerce/stripe`, because importing it pulls in
|
|
57
|
+
* the vendor's whole SDK — and this entry point is imported by browser code
|
|
58
|
+
* that only wants to know what a deposit comes to. Re-exporting it put 30 KB of
|
|
59
|
+
* a payments SDK into a console's first load for a screen that never calls it:
|
|
60
|
+
* a barrel is a bundling decision, and the only way to keep this one pure is to
|
|
61
|
+
* leave the heavy thing out of it.
|
|
62
|
+
*/
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
|
2
|
+
import { enableRlsSql } from '@birtalanrobert/tenancy';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A card kept for later, and what each payment was actually for.
|
|
6
|
+
*
|
|
7
|
+
* ## `mortar_saved_cards`
|
|
8
|
+
*
|
|
9
|
+
* The strongest thing a business can do about people not turning up short of
|
|
10
|
+
* taking their money: nothing leaves the customer's account, and the card is
|
|
11
|
+
* there if a fee is later decided on. A **hold is not a substitute** — providers
|
|
12
|
+
* expire an authorisation within days and an appointment is usually further
|
|
13
|
+
* away than that — which is why this is a table and not a longer-lived hold.
|
|
14
|
+
*
|
|
15
|
+
* The consent is stored as **text with a time**, not as a boolean. A flag says
|
|
16
|
+
* somebody clicked; the sentence says what they were told they were agreeing
|
|
17
|
+
* to, and only the second is worth anything when they say they were not told.
|
|
18
|
+
*
|
|
19
|
+
* ## `kind` on a payment
|
|
20
|
+
*
|
|
21
|
+
* A tip belongs to the person who earned it and a product to neither the
|
|
22
|
+
* service nor the diary. Counting all three as service income tells a business
|
|
23
|
+
* its haircuts are more profitable than they are, and nothing computed
|
|
24
|
+
* afterwards can separate them again — so the distinction goes in the row, with
|
|
25
|
+
* `sale` as the default so every existing payment keeps meaning what it meant.
|
|
26
|
+
*/
|
|
27
|
+
export class AddSavedCardsAndKind1790000000000 implements MigrationInterface {
|
|
28
|
+
name = 'AddSavedCardsAndKind1790000000000';
|
|
29
|
+
|
|
30
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
31
|
+
await queryRunner.query(`
|
|
32
|
+
ALTER TABLE "mortar_payments"
|
|
33
|
+
ADD COLUMN "kind" varchar(16) NOT NULL DEFAULT 'sale'
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
await queryRunner.query(`
|
|
37
|
+
ALTER TABLE "mortar_payments"
|
|
38
|
+
ADD CONSTRAINT "ck_payments_kind"
|
|
39
|
+
CHECK ("kind" IN ('sale', 'deposit', 'fee', 'tip', 'product'))
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
await queryRunner.query(`
|
|
43
|
+
CREATE TABLE "mortar_saved_cards" (
|
|
44
|
+
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
|
45
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
47
|
+
"tenant_id" uuid NOT NULL,
|
|
48
|
+
-- Whose card it is, as the owning product names it. No foreign key, for
|
|
49
|
+
-- the same reason a payment has none: one product hangs a card off a
|
|
50
|
+
-- salon's client, another off a tenant's guest.
|
|
51
|
+
"subject" varchar(160) NOT NULL,
|
|
52
|
+
"provider" varchar(32) NOT NULL DEFAULT 'stripe',
|
|
53
|
+
-- The customer on *our* account. Under a destination charge the money
|
|
54
|
+
-- lands on the business's account while the card stays ours to charge,
|
|
55
|
+
-- and one created on the business's account cannot be used from here.
|
|
56
|
+
"customer_ref" varchar(128) NOT NULL,
|
|
57
|
+
"payment_method_ref" varchar(128) NOT NULL,
|
|
58
|
+
-- Enough to recognise it, and nothing more. We never see the number.
|
|
59
|
+
"brand" varchar(24),
|
|
60
|
+
"last4" varchar(4),
|
|
61
|
+
"expiry_month" int,
|
|
62
|
+
"expiry_year" int,
|
|
63
|
+
-- What they were told they were agreeing to, in the language they read
|
|
64
|
+
-- it in. Required: a card charged on a consent nobody can produce is an
|
|
65
|
+
-- argument the business loses.
|
|
66
|
+
"consent_text" varchar(1000) NOT NULL,
|
|
67
|
+
"consented_at" timestamptz NOT NULL,
|
|
68
|
+
"stored_by" uuid,
|
|
69
|
+
CONSTRAINT "pk_saved_cards" PRIMARY KEY ("id"),
|
|
70
|
+
CONSTRAINT "uq_saved_cards_tenant_id" UNIQUE ("tenant_id", "id"),
|
|
71
|
+
CONSTRAINT "uq_saved_cards_method" UNIQUE ("tenant_id", "payment_method_ref")
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
await queryRunner.query(`
|
|
76
|
+
CREATE INDEX "ix_saved_cards_subject"
|
|
77
|
+
ON "mortar_saved_cards" ("tenant_id", "subject")
|
|
78
|
+
`);
|
|
79
|
+
|
|
80
|
+
for (const statement of enableRlsSql('mortar_saved_cards')) {
|
|
81
|
+
await queryRunner.query(statement);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
86
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "mortar_saved_cards" CASCADE`);
|
|
87
|
+
await queryRunner.query(`
|
|
88
|
+
ALTER TABLE "mortar_payments" DROP CONSTRAINT IF EXISTS "ck_payments_kind"
|
|
89
|
+
`);
|
|
90
|
+
await queryRunner.query(`ALTER TABLE "mortar_payments" DROP COLUMN IF EXISTS "kind"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -7,7 +7,9 @@ import { ConflictError, NotFoundError, ValidationError } from '@birtalanrobert/h
|
|
|
7
7
|
import { canTakeMoney, type PayoutStatus } from '../deposits';
|
|
8
8
|
import type { PaymentProvider, ProviderEvent } from '../providers/port';
|
|
9
9
|
import { PayoutAccount } from './payout-account.entity';
|
|
10
|
+
import { isRefundable, type PaymentKind } from '../payments';
|
|
10
11
|
import { Payment, PaymentRefund, type PaymentMethod } from './payment.entity';
|
|
12
|
+
import { SavedCard } from './saved-card.entity';
|
|
11
13
|
|
|
12
14
|
/** The provider this deployment uses, injected so tests can supply their own. */
|
|
13
15
|
export const COMMERCE_PROVIDER = Symbol('COMMERCE_PROVIDER');
|
|
@@ -18,6 +20,15 @@ export interface TakePayment {
|
|
|
18
20
|
readonly currency: string;
|
|
19
21
|
readonly applicationFee?: number;
|
|
20
22
|
readonly description?: string;
|
|
23
|
+
/** What it is for. `sale` unless said otherwise. */
|
|
24
|
+
readonly kind?: PaymentKind;
|
|
25
|
+
/**
|
|
26
|
+
* A stored card to charge without anybody present.
|
|
27
|
+
*
|
|
28
|
+
* The whole reason for keeping one: a fee is decided days after the fact, and
|
|
29
|
+
* the customer is not there to be asked.
|
|
30
|
+
*/
|
|
31
|
+
readonly savedCardId?: string;
|
|
21
32
|
/** False holds the money instead of taking it. See `capture`. */
|
|
22
33
|
readonly capture?: boolean;
|
|
23
34
|
/**
|
|
@@ -34,9 +45,32 @@ export interface RecordPayment {
|
|
|
34
45
|
readonly amount: number;
|
|
35
46
|
readonly currency: string;
|
|
36
47
|
readonly method: Exclude<PaymentMethod, 'card'>;
|
|
48
|
+
readonly kind?: PaymentKind;
|
|
37
49
|
readonly detail?: string;
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A charge, and what the browser needs to finish it.
|
|
54
|
+
*
|
|
55
|
+
* The secret is returned rather than stored: a charge created on the server has
|
|
56
|
+
* no card attached to it yet, and the card is entered against the provider's own
|
|
57
|
+
* script so the number never reaches us. Without handing it back, a payment can
|
|
58
|
+
* be created and never paid — which a customer reads as "it took my booking and
|
|
59
|
+
* lost my money".
|
|
60
|
+
*/
|
|
61
|
+
export interface TakenPayment {
|
|
62
|
+
readonly payment: Payment;
|
|
63
|
+
readonly clientSecret?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A card being stored, and what the browser confirms it with. */
|
|
67
|
+
export interface CardToSave {
|
|
68
|
+
readonly subject: string;
|
|
69
|
+
readonly reference: string;
|
|
70
|
+
/** What the customer was told they were agreeing to, in their own language. */
|
|
71
|
+
readonly consentText: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
/**
|
|
41
75
|
* Money between a customer and a business, and the record of it.
|
|
42
76
|
*
|
|
@@ -120,13 +154,19 @@ export class CommerceService {
|
|
|
120
154
|
* customer debited and the money nowhere anybody can see it, and the first
|
|
121
155
|
* the business hears is asking where it went.
|
|
122
156
|
*/
|
|
123
|
-
async take(tenantId: string, input: TakePayment): Promise<
|
|
157
|
+
async take(tenantId: string, input: TakePayment): Promise<TakenPayment> {
|
|
124
158
|
const account = await this.payoutAccount(tenantId);
|
|
125
159
|
|
|
126
160
|
if (!account || !canTakeMoney(account.status)) {
|
|
127
161
|
throw new ConflictError('This business cannot take card payments yet.');
|
|
128
162
|
}
|
|
129
163
|
|
|
164
|
+
const saved = input.savedCardId ? await this.savedCard(tenantId, input.savedCardId) : null;
|
|
165
|
+
|
|
166
|
+
if (input.savedCardId && !saved) {
|
|
167
|
+
throw new NotFoundError('SavedCard', input.savedCardId);
|
|
168
|
+
}
|
|
169
|
+
|
|
130
170
|
if (input.amount <= 0) {
|
|
131
171
|
throw new ValidationError(
|
|
132
172
|
[{ field: 'amount', message: 'There is nothing to charge.' }],
|
|
@@ -142,10 +182,11 @@ export class CommerceService {
|
|
|
142
182
|
subject: input.subject,
|
|
143
183
|
capture: input.capture ?? true,
|
|
144
184
|
...(input.description ? { description: input.description } : {}),
|
|
185
|
+
...(saved ? { customer: saved.customerRef, paymentMethod: saved.paymentMethodRef } : {}),
|
|
145
186
|
reference: input.reference,
|
|
146
187
|
});
|
|
147
188
|
|
|
148
|
-
|
|
189
|
+
const payment = await runInTenantTransaction(
|
|
149
190
|
this.dataSource,
|
|
150
191
|
async (scoped) => {
|
|
151
192
|
const repository = scoped.getRepository(Payment);
|
|
@@ -155,6 +196,7 @@ export class CommerceService {
|
|
|
155
196
|
tenantId,
|
|
156
197
|
subject: input.subject,
|
|
157
198
|
method: 'card',
|
|
199
|
+
kind: input.kind ?? 'sale',
|
|
158
200
|
state: result.state,
|
|
159
201
|
amount: String(input.amount),
|
|
160
202
|
currency: input.currency,
|
|
@@ -173,6 +215,130 @@ export class CommerceService {
|
|
|
173
215
|
},
|
|
174
216
|
{ tenantId },
|
|
175
217
|
);
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* The secret is handed back and never written down.
|
|
221
|
+
*
|
|
222
|
+
* It authorises whoever holds it to pay this one charge, so it belongs in
|
|
223
|
+
* the response to the person paying and nowhere else — not in a column, not
|
|
224
|
+
* in a log line, and not in an error.
|
|
225
|
+
*/
|
|
226
|
+
return { payment, ...(result.clientSecret ? { clientSecret: result.clientSecret } : {}) };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Starts storing a card without charging it, and records what was agreed.
|
|
231
|
+
*
|
|
232
|
+
* Two steps, because there is a browser in the middle: this creates the
|
|
233
|
+
* intent and hands back a secret, and `confirmCard` writes the row once the
|
|
234
|
+
* provider says a card really is stored. Believing the browser instead would
|
|
235
|
+
* mean a business holding a card reference that charges nothing.
|
|
236
|
+
*/
|
|
237
|
+
async saveCard(
|
|
238
|
+
tenantId: string,
|
|
239
|
+
input: CardToSave,
|
|
240
|
+
): Promise<{ externalId: string; clientSecret: string }> {
|
|
241
|
+
const existing = await this.savedCards(tenantId, input.subject);
|
|
242
|
+
|
|
243
|
+
const result = await this.provider.saveCard({
|
|
244
|
+
subject: input.subject,
|
|
245
|
+
reference: input.reference,
|
|
246
|
+
// Reuse the customer this person already has, so a second card joins the
|
|
247
|
+
// first rather than creating a stranger with the same name.
|
|
248
|
+
...(existing[0] ? { customer: existing[0].customerRef } : {}),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return { externalId: result.externalId, clientSecret: result.clientSecret };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Writes down the card the provider says is now stored.
|
|
256
|
+
*
|
|
257
|
+
* Read back from the provider rather than taken from the browser: what a page
|
|
258
|
+
* reports is what a page was told to report, and this row is what a business
|
|
259
|
+
* will later charge real money against.
|
|
260
|
+
*/
|
|
261
|
+
async confirmCard(
|
|
262
|
+
tenantId: string,
|
|
263
|
+
externalId: string,
|
|
264
|
+
input: CardToSave,
|
|
265
|
+
): Promise<SavedCard | null> {
|
|
266
|
+
const stored = await this.provider.storedCard(externalId);
|
|
267
|
+
if (!stored) return null;
|
|
268
|
+
|
|
269
|
+
return runInTenantTransaction(
|
|
270
|
+
this.dataSource,
|
|
271
|
+
async (scoped) => {
|
|
272
|
+
const repository = scoped.getRepository(SavedCard);
|
|
273
|
+
|
|
274
|
+
const already = await repository.findOne({
|
|
275
|
+
where: { tenantId, paymentMethodRef: stored.paymentMethod },
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Confirming twice is the ordinary case on a slow connection, not an
|
|
279
|
+
// exotic one, and it must not leave two rows for one card.
|
|
280
|
+
if (already) return already;
|
|
281
|
+
|
|
282
|
+
return repository.save(
|
|
283
|
+
repository.create({
|
|
284
|
+
tenantId,
|
|
285
|
+
subject: input.subject,
|
|
286
|
+
provider: this.provider.name,
|
|
287
|
+
customerRef: stored.customer,
|
|
288
|
+
paymentMethodRef: stored.paymentMethod,
|
|
289
|
+
brand: stored.brand ?? null,
|
|
290
|
+
last4: stored.last4 ?? null,
|
|
291
|
+
expiryMonth: stored.expiryMonth ?? null,
|
|
292
|
+
expiryYear: stored.expiryYear ?? null,
|
|
293
|
+
consentText: input.consentText,
|
|
294
|
+
consentedAt: new Date(),
|
|
295
|
+
storedBy: getActor()?.id ?? null,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
{ tenantId },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Every card kept for somebody, newest first. */
|
|
304
|
+
async savedCards(tenantId: string, subject: string): Promise<SavedCard[]> {
|
|
305
|
+
return runInTenantTransaction(
|
|
306
|
+
this.dataSource,
|
|
307
|
+
(scoped) =>
|
|
308
|
+
scoped
|
|
309
|
+
.getRepository(SavedCard)
|
|
310
|
+
.find({ where: { tenantId, subject }, order: { createdAt: 'DESC' } }),
|
|
311
|
+
{ tenantId },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async savedCard(tenantId: string, id: string): Promise<SavedCard | null> {
|
|
316
|
+
return runInTenantTransaction(
|
|
317
|
+
this.dataSource,
|
|
318
|
+
(scoped) => scoped.getRepository(SavedCard).findOne({ where: { tenantId, id } }),
|
|
319
|
+
{ tenantId },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Forgets a card, at the customer's request or the business's.
|
|
325
|
+
*
|
|
326
|
+
* The provider is told first. A row deleted while the provider still holds
|
|
327
|
+
* the card is a card nobody can see and anybody with the reference can
|
|
328
|
+
* charge; the reverse — a detached card with a row still here — is merely a
|
|
329
|
+
* charge that fails.
|
|
330
|
+
*/
|
|
331
|
+
async forgetCard(tenantId: string, id: string): Promise<void> {
|
|
332
|
+
const card = await this.savedCard(tenantId, id);
|
|
333
|
+
if (!card) return;
|
|
334
|
+
|
|
335
|
+
await this.provider.forgetCard(card.paymentMethodRef);
|
|
336
|
+
|
|
337
|
+
await runInTenantTransaction(
|
|
338
|
+
this.dataSource,
|
|
339
|
+
(scoped) => scoped.getRepository(SavedCard).delete({ tenantId, id }),
|
|
340
|
+
{ tenantId },
|
|
341
|
+
);
|
|
176
342
|
}
|
|
177
343
|
|
|
178
344
|
/**
|
|
@@ -202,6 +368,7 @@ export class CommerceService {
|
|
|
202
368
|
tenantId,
|
|
203
369
|
subject: input.subject,
|
|
204
370
|
method: input.method,
|
|
371
|
+
kind: input.kind ?? 'sale',
|
|
205
372
|
// Captured immediately: the money is in the till. There is no
|
|
206
373
|
// provider to wait for and nothing that can fail later.
|
|
207
374
|
state: 'captured',
|
|
@@ -275,7 +442,7 @@ export class CommerceService {
|
|
|
275
442
|
): Promise<Payment> {
|
|
276
443
|
const payment = await this.find(tenantId, paymentId);
|
|
277
444
|
|
|
278
|
-
if (payment.state
|
|
445
|
+
if (!isRefundable(payment.state)) {
|
|
279
446
|
throw new ConflictError('That payment cannot be refunded.');
|
|
280
447
|
}
|
|
281
448
|
|
package/src/nestjs/index.ts
CHANGED
|
@@ -6,19 +6,31 @@
|
|
|
6
6
|
* browser bundle.
|
|
7
7
|
*/
|
|
8
8
|
export { PayoutAccount } from './payout-account.entity';
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
Payment,
|
|
11
|
+
PaymentRefund,
|
|
12
|
+
type PaymentKind,
|
|
13
|
+
type PaymentMethod,
|
|
14
|
+
type PaymentState,
|
|
15
|
+
} from './payment.entity';
|
|
16
|
+
export { SavedCard } from './saved-card.entity';
|
|
10
17
|
export {
|
|
11
18
|
COMMERCE_PROVIDER,
|
|
12
19
|
CommerceService,
|
|
20
|
+
type CardToSave,
|
|
13
21
|
type RecordPayment,
|
|
22
|
+
type TakenPayment,
|
|
14
23
|
type TakePayment,
|
|
15
24
|
} from './commerce.service';
|
|
16
25
|
export { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
|
|
26
|
+
export { AddSavedCardsAndKind1790000000000 } from '../migrations/1790000000000-AddSavedCardsAndKind';
|
|
17
27
|
|
|
18
28
|
import { PayoutAccount } from './payout-account.entity';
|
|
19
29
|
import { Payment, PaymentRefund } from './payment.entity';
|
|
30
|
+
import { SavedCard } from './saved-card.entity';
|
|
20
31
|
import { CreateCommerce1789800000000 } from '../migrations/1789800000000-CreateCommerce';
|
|
32
|
+
import { AddSavedCardsAndKind1790000000000 } from '../migrations/1790000000000-AddSavedCardsAndKind';
|
|
21
33
|
|
|
22
34
|
/** Register with the data source, the way every other mortar package is. */
|
|
23
|
-
export const commerceEntities = [PayoutAccount, Payment, PaymentRefund];
|
|
24
|
-
export const commerceMigrations = [CreateCommerce1789800000000];
|
|
35
|
+
export const commerceEntities = [PayoutAccount, Payment, PaymentRefund, SavedCard];
|
|
36
|
+
export const commerceMigrations = [CreateCommerce1789800000000, AddSavedCardsAndKind1790000000000];
|
|
@@ -1,33 +1,14 @@
|
|
|
1
1
|
import { Column, Entity, Index, Unique } from 'typeorm';
|
|
2
2
|
import { BaseEntity, MONEY_AMOUNT_COLUMN } from '@birtalanrobert/database';
|
|
3
|
+
import type { PaymentKind, PaymentMethod, PaymentState } from '../payments';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `card` is the only one this package processes. The rest are **recorded, not
|
|
8
|
-
* taken** — and recording them is not a lesser feature: a salon is mostly cash
|
|
9
|
-
* at the counter, a restaurant's till takes meal vouchers, and a box office
|
|
10
|
-
* takes notes. A report that only counts what a provider processed tells a
|
|
11
|
-
* business a fraction of its own takings, which is worse than telling it
|
|
12
|
-
* nothing because it looks complete.
|
|
13
|
-
*/
|
|
14
|
-
export type PaymentMethod = 'card' | 'cash' | 'terminal' | 'voucher' | 'transfer';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Where a payment is.
|
|
5
|
+
/*
|
|
6
|
+
* The lifecycle types live in the pure entry point and are re-exported here.
|
|
18
7
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* that decision is a human one.
|
|
8
|
+
* A console renders a refund button from the same union the entity is typed
|
|
9
|
+
* with, and it must not reach TypeORM to get it.
|
|
22
10
|
*/
|
|
23
|
-
export type PaymentState
|
|
24
|
-
| 'pending'
|
|
25
|
-
| 'authorized'
|
|
26
|
-
| 'captured'
|
|
27
|
-
| 'failed'
|
|
28
|
-
| 'refunded'
|
|
29
|
-
| 'partially_refunded'
|
|
30
|
-
| 'cancelled';
|
|
11
|
+
export type { PaymentKind, PaymentMethod, PaymentState } from '../payments';
|
|
31
12
|
|
|
32
13
|
/**
|
|
33
14
|
* One movement of money between a customer and a business.
|
|
@@ -58,6 +39,17 @@ export class Payment extends BaseEntity {
|
|
|
58
39
|
@Column('varchar', { length: 16 })
|
|
59
40
|
method!: PaymentMethod;
|
|
60
41
|
|
|
42
|
+
/**
|
|
43
|
+
* What it was for.
|
|
44
|
+
*
|
|
45
|
+
* A tip belongs to the person who earned it and a product to neither the
|
|
46
|
+
* service nor the diary — a report counting all three as service income tells
|
|
47
|
+
* a business its haircuts are more profitable than they are, and no amount of
|
|
48
|
+
* arithmetic afterwards can separate them again.
|
|
49
|
+
*/
|
|
50
|
+
@Column('varchar', { length: 16, default: 'sale' })
|
|
51
|
+
kind!: PaymentKind;
|
|
52
|
+
|
|
61
53
|
@Column('varchar', { length: 24, default: 'pending' })
|
|
62
54
|
state!: PaymentState;
|
|
63
55
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Column, Entity, Index, Unique } from 'typeorm';
|
|
2
|
+
import { BaseEntity } from '@birtalanrobert/database';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A card kept for later, with the words the customer agreed to.
|
|
6
|
+
*
|
|
7
|
+
* **The strongest thing a business can do about people not turning up, short of
|
|
8
|
+
* taking their money.** Nothing leaves the customer's account when they book;
|
|
9
|
+
* the card is simply there if a fee is later decided on — and the decision is
|
|
10
|
+
* still a human one made days afterwards, which is exactly when asking somebody
|
|
11
|
+
* to enter a card is a conversation that does not happen.
|
|
12
|
+
*
|
|
13
|
+
* A hold is not a substitute and must not be sold as one: providers expire an
|
|
14
|
+
* authorisation within days, and an appointment is usually further away.
|
|
15
|
+
*
|
|
16
|
+
* **The consent is stored as text, not as a flag.** A boolean records that
|
|
17
|
+
* somebody clicked; the sentence records what they were told they were agreeing
|
|
18
|
+
* to, which is the only thing worth anything when they say they were not.
|
|
19
|
+
*/
|
|
20
|
+
@Entity('mortar_saved_cards')
|
|
21
|
+
@Unique('uq_saved_cards_tenant_id', ['tenantId', 'id'])
|
|
22
|
+
@Index('ix_saved_cards_subject', ['tenantId', 'subject'])
|
|
23
|
+
export class SavedCard extends BaseEntity {
|
|
24
|
+
@Column('uuid')
|
|
25
|
+
tenantId!: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whose card it is, as the owning product names it: `customer:<id>`.
|
|
29
|
+
*
|
|
30
|
+
* No foreign key, for the same reason a payment has none — one product hangs
|
|
31
|
+
* a card off a salon's client, another off a tenant's guest.
|
|
32
|
+
*/
|
|
33
|
+
@Column('varchar', { length: 160 })
|
|
34
|
+
subject!: string;
|
|
35
|
+
|
|
36
|
+
@Column('varchar', { length: 32 })
|
|
37
|
+
provider!: string;
|
|
38
|
+
|
|
39
|
+
/** The customer on *our* account, which is where a saved card can be used. */
|
|
40
|
+
@Column('varchar', { length: 128 })
|
|
41
|
+
customerRef!: string;
|
|
42
|
+
|
|
43
|
+
@Column('varchar', { length: 128 })
|
|
44
|
+
paymentMethodRef!: string;
|
|
45
|
+
|
|
46
|
+
/** Enough to recognise it — "Visa ending 4242" — and nothing more. */
|
|
47
|
+
@Column('varchar', { length: 24, nullable: true })
|
|
48
|
+
brand!: string | null;
|
|
49
|
+
|
|
50
|
+
@Column('varchar', { length: 4, nullable: true })
|
|
51
|
+
last4!: string | null;
|
|
52
|
+
|
|
53
|
+
@Column('int', { name: 'expiry_month', nullable: true })
|
|
54
|
+
expiryMonth!: number | null;
|
|
55
|
+
|
|
56
|
+
@Column('int', { name: 'expiry_year', nullable: true })
|
|
57
|
+
expiryYear!: number | null;
|
|
58
|
+
|
|
59
|
+
/** What they were told they were agreeing to, in the language they read it. */
|
|
60
|
+
@Column('varchar', { length: 1000 })
|
|
61
|
+
consentText!: string;
|
|
62
|
+
|
|
63
|
+
@Column('timestamptz')
|
|
64
|
+
consentedAt!: Date;
|
|
65
|
+
|
|
66
|
+
/** Who stored it, where a member of staff did it on somebody's behalf. */
|
|
67
|
+
@Column('uuid', { nullable: true })
|
|
68
|
+
storedBy!: string | null;
|
|
69
|
+
}
|
package/src/payments.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a payment is, and what may still be done to it.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free, because a console renders a refund button from
|
|
5
|
+
* exactly the rule the server enforces — and a screen that offers a refund the
|
|
6
|
+
* server will refuse is a screen that teaches its user not to trust it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How the money arrived.
|
|
11
|
+
*
|
|
12
|
+
* `card` is the only one this package processes. The rest are **recorded, not
|
|
13
|
+
* taken** — and recording them is not a lesser feature: a salon is mostly cash
|
|
14
|
+
* at the counter, a restaurant's till takes meal vouchers, and a box office
|
|
15
|
+
* takes notes. A report that only counts what a provider processed tells a
|
|
16
|
+
* business a fraction of its own takings, which is worse than telling it
|
|
17
|
+
* nothing because it looks complete.
|
|
18
|
+
*/
|
|
19
|
+
export type PaymentMethod = 'card' | 'cash' | 'terminal' | 'voucher' | 'transfer';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a payment is.
|
|
23
|
+
*
|
|
24
|
+
* `authorized` is separate from `captured` on purpose: a card held against a
|
|
25
|
+
* no-show fee is authorised and never captured unless the fee is applied, and
|
|
26
|
+
* that decision is a human one.
|
|
27
|
+
*/
|
|
28
|
+
export type PaymentState =
|
|
29
|
+
| 'pending'
|
|
30
|
+
| 'authorized'
|
|
31
|
+
| 'captured'
|
|
32
|
+
| 'failed'
|
|
33
|
+
| 'refunded'
|
|
34
|
+
| 'partially_refunded'
|
|
35
|
+
| 'cancelled';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a payment was for.
|
|
39
|
+
*
|
|
40
|
+
* Not decoration: a tip belongs to the person who earned it and a product to
|
|
41
|
+
* neither the service nor the diary, so a revenue report that counts all three
|
|
42
|
+
* as service income tells a business its haircuts are more profitable than they
|
|
43
|
+
* are. The distinction has to survive into the row, because it cannot be
|
|
44
|
+
* recovered from an amount afterwards.
|
|
45
|
+
*/
|
|
46
|
+
export type PaymentKind = 'sale' | 'deposit' | 'fee' | 'tip' | 'product';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether money actually moved, and so can be given back.
|
|
50
|
+
*
|
|
51
|
+
* A held card has taken nothing yet — releasing it is a different operation
|
|
52
|
+
* with a different name — and a failed charge never will. Both look refundable
|
|
53
|
+
* to anyone reasoning from "there is a payment row here", which is why this is
|
|
54
|
+
* one function and not a condition written twice.
|
|
55
|
+
*/
|
|
56
|
+
export const isRefundable = (state: PaymentState): boolean =>
|
|
57
|
+
state === 'captured' || state === 'partially_refunded';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What is left to give back, in minor units.
|
|
61
|
+
*
|
|
62
|
+
* Takes the two amounts rather than a row, because they arrive as strings from
|
|
63
|
+
* a `bigint` column in one caller and as numbers in another, and the subtraction
|
|
64
|
+
* should not be where that difference is discovered.
|
|
65
|
+
*/
|
|
66
|
+
export const refundableAmount = (amount: number, refunded: number): number =>
|
|
67
|
+
Math.max(0, amount - refunded);
|