@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,56 @@
1
+ import { Column, Entity, Index, Unique } from 'typeorm';
2
+ import { BaseEntity } from '@birtalanrobert/database';
3
+ import type { PayoutStatus } from '../deposits';
4
+
5
+ /**
6
+ * Where a business's money goes, and whether the provider will send it yet.
7
+ *
8
+ * **We never hold anybody's funds.** A customer pays the business directly and
9
+ * our fee is taken on top as an application fee — which is a hard architectural
10
+ * rule rather than a preference, because holding third-party money turns a
11
+ * software company into a regulated payments business.
12
+ *
13
+ * The consequence is this table. Until the provider has verified who the
14
+ * business is, there is nowhere for a payment to land, so every product that
15
+ * takes money on somebody's behalf has to gate its selling on the same fact.
16
+ */
17
+ @Entity('mortar_payout_accounts')
18
+ @Unique('uq_payout_accounts_tenant', ['tenantId', 'provider'])
19
+ @Index('ix_payout_accounts_external', ['provider', 'externalId'])
20
+ export class PayoutAccount extends BaseEntity {
21
+ @Column('uuid')
22
+ tenantId!: string;
23
+
24
+ /**
25
+ * Which provider this account is with.
26
+ *
27
+ * A column rather than an assumption, because the markets differ: a local
28
+ * processor with faster onboarding beats a lower fee for a restaurant that
29
+ * wants to be live this afternoon, and one of the seventeen will need one.
30
+ */
31
+ @Column('varchar', { length: 32, default: 'stripe' })
32
+ provider!: string;
33
+
34
+ /** The provider's own identifier for the account. */
35
+ @Column('varchar', { length: 128 })
36
+ externalId!: string;
37
+
38
+ @Column('varchar', { length: 16, default: 'pending' })
39
+ status!: PayoutStatus;
40
+
41
+ /**
42
+ * What the provider still wants, in its own words.
43
+ *
44
+ * Kept verbatim rather than translated into a status of ours. "We need a
45
+ * photograph of the director's identity document" is actionable; "restricted"
46
+ * is a support conversation, and the difference is a business that finishes
47
+ * onboarding on a Sunday evening rather than on Tuesday when somebody
48
+ * telephones them.
49
+ */
50
+ @Column('jsonb', { default: () => `'[]'::jsonb` })
51
+ requirements!: string[];
52
+
53
+ /** Set the first time the provider said it would pay out. */
54
+ @Column('timestamptz', { nullable: true })
55
+ readyAt!: Date | null;
56
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * What a payment provider has to be able to do, and nothing more.
3
+ *
4
+ * A port rather than the vendor's client, for the reason `@birtalanrobert/files`
5
+ * has one over S3: the day a market needs a local processor — and one will,
6
+ * because a restaurant that can be onboarded this afternoon beats a lower fee —
7
+ * nothing above this interface moves.
8
+ *
9
+ * Deliberately small. Everything that can be decided without the provider is
10
+ * decided without it: what a deposit comes to, whether a business may sell yet,
11
+ * what a refund leaves. This is only the part that genuinely needs somebody
12
+ * else's money-moving licence.
13
+ */
14
+
15
+ /** Where a business's money goes, as the provider knows it. */
16
+ export interface ProviderAccount {
17
+ readonly externalId: string;
18
+ readonly status: 'pending' | 'restricted' | 'ready';
19
+ /** What the provider still wants, in its own words. */
20
+ readonly requirements: readonly string[];
21
+ }
22
+
23
+ export interface OnboardingLink {
24
+ readonly url: string;
25
+ readonly expiresAt: Date;
26
+ }
27
+
28
+ export interface ChargeRequest {
29
+ /** The business being paid, as the provider knows it. */
30
+ readonly account: string;
31
+ readonly amount: number;
32
+ readonly currency: string;
33
+ /** Our cut, taken on top rather than out of the business's money. */
34
+ readonly applicationFee: number;
35
+ /** What it is for, carried through so a webhook can be matched back. */
36
+ readonly subject: string;
37
+ /**
38
+ * Whether to take the money now or only hold it.
39
+ *
40
+ * Holding is the anti-no-show mechanism: a card is authorised and charged
41
+ * only if a fee is actually applied, and **that decision is a human one**.
42
+ */
43
+ readonly capture: boolean;
44
+ readonly description?: string;
45
+ /** An idempotency key, so a retried request does not charge twice. */
46
+ readonly reference: string;
47
+ }
48
+
49
+ export interface ChargeResult {
50
+ readonly externalId: string;
51
+ readonly state: 'pending' | 'authorized' | 'captured' | 'failed';
52
+ /**
53
+ * Where to send the customer to finish, when the provider needs them.
54
+ *
55
+ * 3-D Secure and bank redirects are the ordinary case in both target
56
+ * markets rather than an exception, so a charge that returns a URL is not a
57
+ * failure and must not be handled as one.
58
+ */
59
+ readonly redirectUrl?: string;
60
+ readonly instrument?: string;
61
+ readonly detail?: string;
62
+ }
63
+
64
+ export interface RefundRequest {
65
+ readonly externalId: string;
66
+ readonly amount: number;
67
+ readonly reason: string;
68
+ readonly reference: string;
69
+ }
70
+
71
+ /** What a provider's webhook turned out to be about. */
72
+ export interface ProviderEvent {
73
+ readonly kind: 'payment' | 'account' | 'other';
74
+ readonly externalId: string;
75
+ readonly state?: 'authorized' | 'captured' | 'failed' | 'refunded';
76
+ readonly accountStatus?: ProviderAccount;
77
+ readonly instrument?: string;
78
+ readonly detail?: string;
79
+ }
80
+
81
+ export interface PaymentProvider {
82
+ readonly name: string;
83
+
84
+ /** Starts or resumes onboarding, and says where to send the business. */
85
+ onboard(tenantId: string, returnUrl: string, refreshUrl: string): Promise<OnboardingLink>;
86
+
87
+ /** Creates the account if there is none, and reports where it stands. */
88
+ account(externalId: string | null, country: string, email?: string): Promise<ProviderAccount>;
89
+
90
+ charge(request: ChargeRequest): Promise<ChargeResult>;
91
+
92
+ /** Takes money that was only held. The human decision has been made. */
93
+ capture(externalId: string, amount?: number): Promise<ChargeResult>;
94
+
95
+ /** Releases a hold without taking anything. */
96
+ release(externalId: string): Promise<void>;
97
+
98
+ refund(request: RefundRequest): Promise<{ externalId: string }>;
99
+
100
+ /**
101
+ * Whether a webhook really came from the provider, and what it says.
102
+ *
103
+ * `undefined` rather than a thrown error: the caller's answer to a request
104
+ * that did not come from the provider is a flat acknowledgement, not a
105
+ * message describing what was wrong with the forgery.
106
+ */
107
+ verify(payload: string | Buffer, signature: string | undefined): ProviderEvent | undefined;
108
+ }
@@ -0,0 +1,274 @@
1
+ import Stripe from 'stripe';
2
+ import type {
3
+ ChargeRequest,
4
+ ChargeResult,
5
+ OnboardingLink,
6
+ PaymentProvider,
7
+ ProviderAccount,
8
+ ProviderEvent,
9
+ RefundRequest,
10
+ } from './port';
11
+
12
+ export interface StripeConnectOptions {
13
+ secretKey: string;
14
+ /** The endpoint's signing secret, as the dashboard gives it: `whsec_…`. */
15
+ webhookSecret?: string;
16
+ /** An already-built client, for tests and for a deployment that shares one. */
17
+ client?: Stripe;
18
+ }
19
+
20
+ /**
21
+ * Stripe Connect, as the port describes it.
22
+ *
23
+ * **Destination charges throughout.** The money is created on our platform
24
+ * account and transferred immediately to the business, with our cut taken as an
25
+ * application fee — which is what keeps this a software company rather than a
26
+ * regulated one, and what lets the business see its own payouts in its own
27
+ * Stripe dashboard.
28
+ *
29
+ * Everything the provider does *not* need to decide is decided before this file
30
+ * is reached: what a deposit comes to, whether a business may sell, what a
31
+ * refund leaves. What is here is the part that genuinely needs somebody else's
32
+ * money-moving licence.
33
+ */
34
+ export class StripeConnect implements PaymentProvider {
35
+ readonly name = 'stripe';
36
+ private readonly stripe: Stripe;
37
+
38
+ constructor(private readonly options: StripeConnectOptions) {
39
+ this.stripe = options.client ?? new Stripe(options.secretKey);
40
+ }
41
+
42
+ async account(
43
+ externalId: string | null,
44
+ country: string,
45
+ email?: string,
46
+ ): Promise<ProviderAccount> {
47
+ const account = externalId
48
+ ? await this.stripe.accounts.retrieve(externalId)
49
+ : await this.stripe.accounts.create({
50
+ type: 'express',
51
+ country,
52
+ ...(email ? { email } : {}),
53
+ capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
54
+ });
55
+
56
+ return interpretAccount(account);
57
+ }
58
+
59
+ async onboard(
60
+ externalId: string,
61
+ returnUrl: string,
62
+ refreshUrl: string,
63
+ ): Promise<OnboardingLink> {
64
+ const link = await this.stripe.accountLinks.create({
65
+ account: externalId,
66
+ type: 'account_onboarding',
67
+ return_url: returnUrl,
68
+ /*
69
+ * Where the business lands if the link has aged out.
70
+ *
71
+ * Stripe's onboarding links are short-lived and somebody *will* open one
72
+ * the next morning. Without this they meet an error page from a company
73
+ * they have never heard of, halfway through giving it their passport.
74
+ */
75
+ refresh_url: refreshUrl,
76
+ });
77
+
78
+ return { url: link.url, expiresAt: new Date(link.expires_at * 1000) };
79
+ }
80
+
81
+ async charge(request: ChargeRequest): Promise<ChargeResult> {
82
+ try {
83
+ const intent = await this.stripe.paymentIntents.create(
84
+ {
85
+ amount: request.amount,
86
+ currency: request.currency.toLowerCase(),
87
+ /*
88
+ * The money lands on the business's account, not ours.
89
+ *
90
+ * `transfer_data.destination` with `application_fee_amount` is the
91
+ * destination-charge shape: we never hold their funds, and their
92
+ * payouts appear in their own dashboard.
93
+ */
94
+ transfer_data: { destination: request.account },
95
+ ...(request.applicationFee > 0 ? { application_fee_amount: request.applicationFee } : {}),
96
+ capture_method: request.capture ? 'automatic' : 'manual',
97
+ ...(request.description ? { description: request.description } : {}),
98
+ // Carried through so a webhook can be matched back to what it paid
99
+ // for without a lookup table of our own.
100
+ metadata: { subject: request.subject },
101
+ automatic_payment_methods: { enabled: true },
102
+ },
103
+ // The provider's own idempotency, so a retried request — a timeout, a
104
+ // double submit — does not charge somebody twice.
105
+ { idempotencyKey: request.reference },
106
+ );
107
+
108
+ return interpretIntent(intent);
109
+ } catch (error) {
110
+ return failure(error);
111
+ }
112
+ }
113
+
114
+ async capture(externalId: string, amount?: number): Promise<ChargeResult> {
115
+ try {
116
+ const intent = await this.stripe.paymentIntents.capture(
117
+ externalId,
118
+ amount === undefined ? undefined : { amount_to_capture: amount },
119
+ );
120
+
121
+ return interpretIntent(intent);
122
+ } catch (error) {
123
+ return failure(error);
124
+ }
125
+ }
126
+
127
+ async release(externalId: string): Promise<void> {
128
+ await this.stripe.paymentIntents.cancel(externalId);
129
+ }
130
+
131
+ async refund(request: RefundRequest): Promise<{ externalId: string }> {
132
+ const refund = await this.stripe.refunds.create(
133
+ {
134
+ payment_intent: request.externalId,
135
+ amount: request.amount,
136
+ metadata: { reason: request.reason.slice(0, 500) },
137
+ },
138
+ { idempotencyKey: request.reference },
139
+ );
140
+
141
+ return { externalId: refund.id };
142
+ }
143
+
144
+ verify(payload: string | Buffer, signature: string | undefined): ProviderEvent | undefined {
145
+ if (!signature || !this.options.webhookSecret) return undefined;
146
+
147
+ let event: Stripe.Event;
148
+
149
+ try {
150
+ event = this.stripe.webhooks.constructEvent(payload, signature, this.options.webhookSecret);
151
+ } catch {
152
+ /*
153
+ * A forgery, or a payload something re-encoded on the way in.
154
+ *
155
+ * `undefined` rather than a thrown error: the caller answers a request
156
+ * that did not come from Stripe with a flat acknowledgement, not with a
157
+ * message describing what was wrong with it.
158
+ */
159
+ return undefined;
160
+ }
161
+
162
+ return interpretEvent(event);
163
+ }
164
+ }
165
+
166
+ /** Stripe's account shape, reduced to the question anybody actually asks. */
167
+ function interpretAccount(account: Stripe.Account): ProviderAccount {
168
+ const requirements = [
169
+ ...(account.requirements?.currently_due ?? []),
170
+ ...(account.requirements?.past_due ?? []),
171
+ ];
172
+
173
+ /*
174
+ * `charges_enabled` and `payouts_enabled` together, not either alone.
175
+ *
176
+ * A business that can take money but cannot be paid out is worse than one
177
+ * that cannot sell yet: the customer is charged and the money sits with the
178
+ * provider, and the first anybody hears is the business asking where it is.
179
+ */
180
+ const ready = account.charges_enabled === true && account.payouts_enabled === true;
181
+
182
+ return {
183
+ externalId: account.id,
184
+ status: ready ? 'ready' : requirements.length > 0 ? 'restricted' : 'pending',
185
+ requirements: [...new Set(requirements)],
186
+ };
187
+ }
188
+
189
+ function interpretIntent(intent: Stripe.PaymentIntent): ChargeResult {
190
+ const state =
191
+ intent.status === 'succeeded'
192
+ ? 'captured'
193
+ : intent.status === 'requires_capture'
194
+ ? 'authorized'
195
+ : intent.status === 'canceled'
196
+ ? 'failed'
197
+ : 'pending';
198
+
199
+ const charge = intent.latest_charge;
200
+ const card =
201
+ typeof charge === 'object' && charge?.payment_method_details?.card
202
+ ? `${charge.payment_method_details.card.brand} ending ${charge.payment_method_details.card.last4}`
203
+ : undefined;
204
+
205
+ return {
206
+ externalId: intent.id,
207
+ state,
208
+ ...(intent.next_action?.redirect_to_url?.url
209
+ ? { redirectUrl: intent.next_action.redirect_to_url.url }
210
+ : {}),
211
+ ...(card ? { instrument: card } : {}),
212
+ ...(intent.last_payment_error?.message ? { detail: intent.last_payment_error.message } : {}),
213
+ };
214
+ }
215
+
216
+ function interpretEvent(event: Stripe.Event): ProviderEvent {
217
+ switch (event.type) {
218
+ case 'payment_intent.succeeded':
219
+ case 'payment_intent.amount_capturable_updated':
220
+ case 'payment_intent.payment_failed': {
221
+ const intent = event.data.object as Stripe.PaymentIntent;
222
+ const result = interpretIntent(intent);
223
+
224
+ return {
225
+ kind: 'payment',
226
+ externalId: intent.id,
227
+ state:
228
+ event.type === 'payment_intent.payment_failed'
229
+ ? 'failed'
230
+ : result.state === 'captured'
231
+ ? 'captured'
232
+ : 'authorized',
233
+ ...(result.instrument ? { instrument: result.instrument } : {}),
234
+ ...(result.detail ? { detail: result.detail } : {}),
235
+ };
236
+ }
237
+
238
+ case 'charge.refunded': {
239
+ const charge = event.data.object as Stripe.Charge;
240
+ return {
241
+ kind: 'payment',
242
+ externalId: typeof charge.payment_intent === 'string' ? charge.payment_intent : charge.id,
243
+ state: 'refunded',
244
+ };
245
+ }
246
+
247
+ case 'account.updated': {
248
+ const account = event.data.object as Stripe.Account;
249
+ return { kind: 'account', externalId: account.id, accountStatus: interpretAccount(account) };
250
+ }
251
+
252
+ default:
253
+ /*
254
+ * Everything else is acknowledged and ignored.
255
+ *
256
+ * Stripe sends a great many event types and a deployment's subscription
257
+ * will drift; treating an unknown one as an error means retries and an
258
+ * alert for something that was never any of our business.
259
+ */
260
+ return { kind: 'other', externalId: event.id };
261
+ }
262
+ }
263
+
264
+ /** A provider's refusal, as a result rather than an exception. */
265
+ function failure(error: unknown): ChargeResult {
266
+ const message =
267
+ error instanceof Stripe.errors.StripeError
268
+ ? (error.message ?? 'The payment was refused.')
269
+ : error instanceof Error
270
+ ? error.message
271
+ : 'The payment was refused.';
272
+
273
+ return { externalId: '', state: 'failed', detail: message };
274
+ }