GS2-Money Transaction Actions
Combining actions, and concurrency
The background common to every service is collected in Combining Transaction Actions. Read that first; the rest of this section is what GS2-Money adds to it.
The transaction actions of GS2-Money address two kinds of target.
- Wallet: identified by the combination of namespace, user, and slot. It is rewritten as a whole on every update.
- Receipt: identified by the transaction id taken out of the receipt. It records one charge. Only one of them fits in a transaction, per user.
| Operation | Repeated in one transaction | Across a nested transaction | Boundary that separates targets | Under sequential execution mode |
|---|---|---|---|---|
Depositing into a walletDepositByUserId | Combined at the same price, and the counts added up. Deposits at differing prices fail | Fails | namespace, user, slot | Succeeds even at differing prices, and even across a nested transaction: each deposit now rewrites the wallet it reads fresh, so a later deposit’s rewrite already contains the price entry the earlier one added |
Withdrawing from a walletWithdrawByUserId | Combined, and the counts are added up | Fails | namespace, user, slot | Succeeds; combined and added up even across a nested transaction |
Recording a receiptRecordReceipt | Fails | Fails on the same receipt | namespace, user (the row itself is per receipt) | Still fails on the same receipt. The record checks that the receipt has not been recorded yet, and under sequential execution the second one reads the row the first one wrote and is rejected as a duplicate (400). Only the point at which the error is raised changes. The same-transaction rejection is unaffected — it happens when the transaction is issued, before any action runs |
Reverting a receipt recordRevertRecordReceipt | Fails | Does not fail | namespace, user (the row itself is per receipt) | Fails across a nested transaction, where it used to pass: the second revert runs against a receipt record that is already gone and is rejected as not found (404) |
Two receipt records, or two reverts, against one user are rejected when the transaction is issued, even when they carry different receipts. A receipt reaches its row through a transaction id, and how that id is read out of the receipt differs by store, so two receipts that look different can still be the same charge. Rather than guess, GS2 asks you to keep one per transaction. Buying a display item that records a receipt with a quantity of 2 or more runs into this as well, because the actions are stacked once per unit.
The last column is where the check draws the line, not where the row is. A receipt still lives in a row of its own, so what comes from inside a nested transaction is judged against the row: two records of the same receipt collide, two records of different receipts do not, and two reverts merge into one because deleting the same row twice is the same as deleting it once.
Depositing and withdrawing are separate rows sharing a boundary, so a deposit and a withdrawal against the same wallet cannot go in one transaction. Turning on sequential execution mode (enableSequentialExecution or TransactionSettingV2) removes this restriction as well, for the same reason: whichever of the two runs second reads the wallet as the first left it, so a deposit and a withdrawal against the same wallet can now be placed in one transaction.
A deposit carries a price as well as a count, and deposits at differing prices cannot be added up. They stay as separate actions and collide on the wallet. Split the transaction, or deposit once per price in separate transactions. GS2-Money2 does not have this restriction, because a deposit there carries a list rather than a single amount.
The balance is judged against the total after combining. A withdrawal that fits on its own can still be rejected when combined with another one.
A paid-only withdrawal and an ordinary one cannot be placed against the same slot. Adding them up while they disagree on which balance to draw from would take the money out of a balance you did not intend, so withdrawals with differing paidOnly against one wallet are rejected when the transaction is issued.
A wallet and a receipt are separate targets, and a different slot is a different wallet, so depositing into several slots in one transaction is fine.
Take care with nested transactions
A wallet is rewritten as a whole on every update, so touching it both from the inside and from the outside makes the transaction fail. Even two withdrawals, which would have been combined if written side by side, do not go through when one of them arrives from the inside.
Currency is a common cost, so this is easy to hit: a purchase that charges the wallet on the outside and a prize that also charges it on the inside will not go through together.
Turning on sequential execution mode removes this failure too: the inner transaction now runs inside the same sequential section as the outer one, and each action rewrites the wallet as the one before it left it, so a wallet touched from both routes is combined instead of colliding. A paid-only withdrawal placed alongside a normal one is a separate matter: two withdrawals written side by side in one transaction whose paidOnly differs are rejected when the transaction is issued, regardless of execution mode. That check only sees withdrawals issued together, so it does not apply where one of them arrives from a nested transaction.
If you want to avoid these restrictions
Depositing into a wallet and reverting a receipt record are acquire actions; withdrawing and recording a receipt are consume actions. Turning acquireActionUseJobQueue on clears collisions between deposits, but a deposit together with a withdrawal needs enableAtomicCommit turned off. Since currency is involved, weigh that decision carefully.
Neither recording a receipt nor reverting a record has such an escape. The check runs before either setting is applied, and a receipt is one charge that must not be recorded or reverted twice, so turning them on changes nothing here. Split it into separate transactions, and buy a display item that records a receipt one at a time.
Concurrency and retries
Updates to a wallet are written with a revision check, so when several requests update the same wallet at the same time, the one confirmed later returns a conflict (409). Nothing is wrong with the request, so retrying will succeed, and the balance is re-evaluated on the retry.
Consume Action
Gs2Money:WithdrawByUserId
Consume balance from Wallet by User ID
Withdraws the specified amount of currency from the wallet for the specified user. If paidOnly is false, free currency is consumed first, then paid currency.
Quantity specification supported: YES
Reversible action: YES
| Type | Condition | Required | Default | Value Limits | Description | |
|---|---|---|---|---|---|---|
| namespaceName | string | ✓ | ~ 128 chars | Namespace name Unique Namespace name. Specified using alphanumeric characters, hyphens (-), underscores (_), and periods (.). | ||
| userId | string | ✓ | ~ 128 chars | User ID Specify #{userId} to substitute the currently logged-in user’s ID. | ||
| slot | int | ✓ | 0 ~ 100000000 | Slot Number An identifier for separating wallet balances by platform or context. Different slots allow managing separate paid currency pools (e.g., iOS purchases in slot 0, Android in slot 1). Free currency can optionally be shared across all slots via the Namespace’s shareFree setting. | ||
| count | int | ✓ | 1 ~ 2147483646 | Quantity of premium currency to be consumed | ||
| paidOnly | bool | false | Whether to target only paid currency | |||
| timeOffsetToken | string | ~ 1024 chars | Time offset token |
{
"action": "Gs2Money:WithdrawByUserId",
"request": {
"namespaceName": "[string]Namespace name",
"userId": "[string]User ID",
"slot": "[int]Slot Number",
"count": "[int]Quantity of premium currency to be consumed",
"paidOnly": "[bool]Whether to target only paid currency",
"timeOffsetToken": "[string]Time offset token"
}
}action: Gs2Money:WithdrawByUserId
request:
namespaceName: "[string]Namespace name"
userId: "[string]User ID"
slot: "[int]Slot Number"
count: "[int]Quantity of premium currency to be consumed"
paidOnly: "[bool]Whether to target only paid currency"
timeOffsetToken: "[string]Time offset token"transaction.service("money").consume.withdraw_by_user_id({
namespaceName="[string]Namespace name",
userId="[string]User ID",
slot="[int]Slot Number",
count="[int]Quantity of premium currency to be consumed",
paidOnly="[bool]Whether to target only paid currency",
timeOffsetToken="[string]Time offset token",
})Gs2Money:RecordReceipt
Record receipt
Records and validates a purchase receipt from a store platform (Apple App Store / Google Play). The receipt is verified against the platform’s servers to prevent fraud. Duplicate receipts are rejected to prevent replay attacks.
Quantity specification supported: NO
Reversible action: NO
| Type | Condition | Required | Default | Value Limits | Description | |
|---|---|---|---|---|---|---|
| namespaceName | string | ✓ | ~ 128 chars | Namespace name Unique Namespace name. Specified using alphanumeric characters, hyphens (-), underscores (_), and periods (.). | ||
| userId | string | ✓ | ~ 128 chars | User ID Specify #{userId} to substitute the currently logged-in user’s ID. | ||
| contentsId | string | ✓ | ~ 1024 chars | Content IDs sold on the store platform | ||
| receipt | string | ✓ | ~ 524288 chars | Receipt | ||
| timeOffsetToken | string | ~ 1024 chars | Time offset token |
{
"action": "Gs2Money:RecordReceipt",
"request": {
"namespaceName": "[string]Namespace name",
"userId": "[string]User ID",
"contentsId": "[string]Content IDs sold on the store platform",
"receipt": "[string]Receipt",
"timeOffsetToken": "[string]Time offset token"
}
}action: Gs2Money:RecordReceipt
request:
namespaceName: "[string]Namespace name"
userId: "[string]User ID"
contentsId: "[string]Content IDs sold on the store platform"
receipt: "[string]Receipt"
timeOffsetToken: "[string]Time offset token"transaction.service("money").consume.record_receipt({
namespaceName="[string]Namespace name",
userId="[string]User ID",
contentsId="[string]Content IDs sold on the store platform",
receipt="[string]Receipt",
timeOffsetToken="[string]Time offset token",
})Acquire Action
Gs2Money:DepositByUserId
Deposit balance to Wallet by User ID
Adds the specified amount of currency to the wallet for the specified user. If the price is 0, it is treated as free currency; otherwise, it is treated as paid currency.
Quantity specification supported: YES
Reversible action: YES
| Type | Condition | Required | Default | Value Limits | Description | |
|---|---|---|---|---|---|---|
| namespaceName | string | ✓ | ~ 128 chars | Namespace name Unique Namespace name. Specified using alphanumeric characters, hyphens (-), underscores (_), and periods (.). | ||
| userId | string | ✓ | ~ 128 chars | User ID Specify #{userId} to substitute the currently logged-in user’s ID. | ||
| slot | int | ✓ | 0 ~ 100000000 | Slot Number An identifier for separating wallet balances by platform or context. Different slots allow managing separate paid currency pools (e.g., iOS purchases in slot 0, Android in slot 1). Free currency can optionally be shared across all slots via the Namespace’s shareFree setting. | ||
| price | float | ✓ | 0 ~ 100000.0 | Purchase Price | ||
| count | int | ✓ | 1 ~ 2147483646 | Quantity of premium currency to be granted | ||
| timeOffsetToken | string | ~ 1024 chars | Time offset token |
{
"action": "Gs2Money:DepositByUserId",
"request": {
"namespaceName": "[string]Namespace name",
"userId": "[string]User ID",
"slot": "[int]Slot Number",
"price": "[float]Purchase Price",
"count": "[int]Quantity of premium currency to be granted",
"timeOffsetToken": "[string]Time offset token"
}
}action: Gs2Money:DepositByUserId
request:
namespaceName: "[string]Namespace name"
userId: "[string]User ID"
slot: "[int]Slot Number"
price: "[float]Purchase Price"
count: "[int]Quantity of premium currency to be granted"
timeOffsetToken: "[string]Time offset token"transaction.service("money").acquire.deposit_by_user_id({
namespaceName="[string]Namespace name",
userId="[string]User ID",
slot="[int]Slot Number",
price="[float]Purchase Price",
count="[int]Quantity of premium currency to be granted",
timeOffsetToken="[string]Time offset token",
})Gs2Money:RevertRecordReceipt
Delete receipt record by User ID
Reverts a previously recorded receipt by extracting the transaction ID and deleting the corresponding record. Used for handling refunds or chargebacks from the store platform.
Quantity specification supported: NO
Reversible action: NO
| Type | Condition | Required | Default | Value Limits | Description | |
|---|---|---|---|---|---|---|
| namespaceName | string | ✓ | ~ 128 chars | Namespace name Unique Namespace name. Specified using alphanumeric characters, hyphens (-), underscores (_), and periods (.). | ||
| userId | string | ✓ | ~ 128 chars | User ID Specify #{userId} to substitute the currently logged-in user’s ID. | ||
| receipt | string | ✓ | ~ 524288 chars | Receipt | ||
| timeOffsetToken | string | ~ 1024 chars | Time offset token |
{
"action": "Gs2Money:RevertRecordReceipt",
"request": {
"namespaceName": "[string]Namespace name",
"userId": "[string]User ID",
"receipt": "[string]Receipt",
"timeOffsetToken": "[string]Time offset token"
}
}action: Gs2Money:RevertRecordReceipt
request:
namespaceName: "[string]Namespace name"
userId: "[string]User ID"
receipt: "[string]Receipt"
timeOffsetToken: "[string]Time offset token"transaction.service("money").acquire.revert_record_receipt({
namespaceName="[string]Namespace name",
userId="[string]User ID",
receipt="[string]Receipt",
timeOffsetToken="[string]Time offset token",
})